Compare commits

...

56 Commits

Author SHA1 Message Date
Jan Böhmer
7d6b84af3d Bumped version to 2.7.0 2026-02-16 13:32:13 +01:00
Copilot
80492a7b68 Use native ARM runners for ARM Docker image builds (#1248)
* Initial plan

* Use ARM runners for ARM Docker image builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix artifact naming and add comments for latest=false flavor

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove trailing commas from tag configuration in merge job

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove duplicate tag entries and clean up configuration

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-02-16 13:15:52 +01:00
Copilot
7069af4054 Updated dockerfiles to not rely on node deb packages, that are not supported for armhf anymore
* Initial plan

* Refactor Dockerfiles to use Node.js multistage builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix node-builder stage with stub translations file

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve stub translations file creation using heredoc

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Build real translations in node-builder stage via cache warmup

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve error handling for cache warmup fallback

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Use native build platform for node-builder stage

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Do not include fallback for case that translations not exist

* Use newer format for dockerfile-frankenphp

* Dockfile added caching to package managers

* Fixed frankenphp build

* Fixed complain about missing symfony runtime

* Use caching for frankenphp dockerfile

* add target arch to dockerfile caches, to avoid problems

* add targetarch arg

* moved targetarch argument to correct position

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-16 12:50:52 +01:00
Jan Böhmer
05a9e4d035 Merge remote-tracking branch 'origin/master' 2026-02-15 22:33:23 +01:00
Jan Böhmer
be808e28bc Updated dependencies 2026-02-15 22:29:16 +01:00
Jan Böhmer
7354b37ef6 New Crowdin updates (#1228)
* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations validators.en.xlf (Polish)

* New translations security.en.xlf (Danish)

* New translations security.en.xlf (Ukrainian)

* New translations security.en.xlf (German)

* New translations security.en.xlf (Hungarian)

* New translations security.en.xlf (Dutch)

* New translations security.en.xlf (Chinese Simplified)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (English)

* New translations security.en.xlf (English)

* New translations frontend.en.xlf (Danish)

* New translations frontend.en.xlf (German)

* New translations frontend.en.xlf (Hungarian)

* New translations frontend.en.xlf (Ukrainian)

* New translations frontend.en.xlf (Chinese Simplified)

* New translations frontend.en.xlf (English)

* New translations messages.en.xlf (German)
2026-02-15 22:24:00 +01:00
Jan Böhmer
6afca44897 Use xxh3 hashes instead of encoding for info provider cache keys 2026-02-15 22:19:44 +01:00
Jan Böhmer
c17cf2baa1 Fixed rendering of tristate checkboxes 2026-02-15 21:49:18 +01:00
Jan Böhmer
c00556829a Focus the first newly created number input for collection_types
Improves PR #1240
2026-02-15 21:43:47 +01:00
Jan Böhmer
f024c4b09e Merge branch 'autofocus-fields' 2026-02-15 21:37:12 +01:00
Jan Böhmer
8e0fcdb73b Added some part datatables optimization 2026-02-15 20:07:38 +01:00
Jan Böhmer
e19929249f Mark parts datatables query as read only for some memory optimizations 2026-02-15 19:30:53 +01:00
Jan Böhmer
f6764170e1 Fixed phpstan issues 2026-02-15 16:16:15 +01:00
Niklas
1641708508 Added API endpoint for generating labels (#1234)
* init API endpoint for generating labels

* Improved API docs for label endpoint

* Improved LabelGenerationProcessor

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-15 16:03:07 +01:00
d-buchmann
97a74815d3 Fix fallback filename (#1238)
Fixes #1231.
Modify tests to account for this case.
2026-02-15 14:41:25 +01:00
Jan Böhmer
7998cdcd71 Added hint about HTML block to twig label documentation 2026-02-15 14:24:31 +01:00
Jan Böhmer
5e9f7a11a3 Catch more errors of twig labels 2026-02-15 14:11:31 +01:00
Jan Böhmer
1c6bf3f472 Allow more useful functions in twig labels 2026-02-15 14:07:50 +01:00
Jan Böhmer
aed2652f1d Added functions to retrieve associated parts of an element within twig labels
This fixes #1239
2026-02-15 13:52:56 +01:00
Jan Böhmer
233c5e8550 Fixed phpunit and phpstan issues 2026-02-15 00:49:12 +01:00
Jan Böhmer
6b83c772cc Moved user twig functions requiring repo access to its own extension service 2026-02-15 00:28:40 +01:00
Jan Böhmer
1996db6a53 Moved remaining twig extensions to new attributes system 2026-02-15 00:23:30 +01:00
Jan Böhmer
f69b0889eb Ran rector to convert some our twig extensions to use #[AsTwigXX] attributes 2026-02-14 23:53:31 +01:00
Jan Böhmer
c8b1320bb9 Updated rector config 2026-02-14 23:50:42 +01:00
Jan Böhmer
e11cb7d5cb Fixed phpunit tests 2026-02-14 23:46:39 +01:00
Jan Böhmer
097041a43a Ran rector 2026-02-14 23:33:40 +01:00
Jan Böhmer
b21d294cf8 Ran rector and made tests final 2026-02-14 23:32:43 +01:00
Jan Böhmer
43d72faf48 Updated label fonts 2026-02-14 22:46:46 +01:00
Jan Böhmer
bc9a93d71f Removed sodium compat, as all supported PHP versions support it natively nowadays 2026-02-14 22:31:53 +01:00
Jan Böhmer
df0ac76394 Updated composer dependencies that required major version changes 2026-02-14 22:24:36 +01:00
Jan Böhmer
66040b687f Updated dependencies 2026-02-14 22:17:05 +01:00
Jan Böhmer
7a83581597 Merge branch 'gtin' 2026-02-14 22:12:39 +01:00
buchmann
47c0d78985 only autofocus if new 2026-02-11 14:26:36 +01:00
buchmann
76f0b05a09 Autofocus for frequently used input fields
Fixes #1157.
- Focus `name` field on new part
- Focus `amount` on add/withdraw modal
- Focus first "number type" input on any newly added collectionType table row... (debatable)

It would be even more favorable if the user could configure if they want to use autofocus and/or for which fields/dialogs it should be enabled.
2026-02-11 14:10:05 +01:00
Jan Böhmer
35598df354 Automatically set the stocktake permission if a user can already add and withdraw from a lot 2026-02-10 23:24:40 +01:00
Jan Böhmer
3c87fe0932 Added test for stocktake method on PartLotWithdrawAddHelper 2026-02-10 23:19:57 +01:00
Jan Böhmer
d8fdaa9529 Added a modal to stocktake / set part lots amount from info page 2026-02-10 23:17:10 +01:00
Jan Böhmer
2f9601364e Allow to set stocktake date for part lots 2026-02-10 22:23:54 +01:00
Jan Böhmer
e5231e29f2 Allow to set a global default if new orderdetails should contain VAT or not 2026-02-10 17:13:54 +01:00
Jan Böhmer
8ac8743792 Fixed phpunit tests 2026-02-10 16:54:13 +01:00
Jan Böhmer
586375d921 Moved VAT include info from pricedetail to orderdetail level
That makes implementing the form easier
2026-02-10 16:53:41 +01:00
Marc
41252d8bb9 Implement URLHandlerInfoProviderInterface in BuerklinProvider (#1235)
* Implement URLHandlerInfoProviderInterface in BuerklinProvider

Added URL handling capabilities to BuerklinProvider.

* Refactor ID extraction logic in BuerklinProvider

* Add tests for BuerklinProvider URLHandlerInfoProviderInterface

* Revert "Refactor ID extraction logic in BuerklinProvider"

This reverts commit 5f65176636.

* Exclude 'p' from valid ID return in BuerklinProvider
2026-02-10 15:26:26 +01:00
Jan Böhmer
4740b6d19e Show in part info page whether price is inclusive VAT or not 2026-02-08 22:09:36 +01:00
Jan Böhmer
5a47b15c97 Use the information from info provider whether prices includes VAT or not 2026-02-08 21:58:14 +01:00
Jan Böhmer
3bff5fa8bd Allow to set if prices contain VAT or not in orderdetail 2026-02-08 21:54:34 +01:00
Jan Böhmer
f95e39748e Fixed PHPstan issue 2026-02-08 19:37:44 +01:00
Jan Böhmer
90c82aab2e Only show the created avatar attachment type for user attachments 2026-02-08 19:31:45 +01:00
Jan Böhmer
a4c2b8f885 Added the option to only show attachment types for certain element classes 2026-02-08 19:30:06 +01:00
Jan Böhmer
2c56ec746c Improved translation 2026-02-08 16:07:11 +01:00
Jan Böhmer
35e844dd7b Allow to scan gtin barcodes and find parts via it 2026-02-08 16:06:01 +01:00
Jan Böhmer
4de6dbba27 Show GTIN in part extended info tab 2026-02-08 15:53:45 +01:00
Jan Böhmer
a962e5e019 Allow to order and filter by GTIN in part tables 2026-02-08 15:51:39 +01:00
Jan Böhmer
1130f71075 Added ability to get GTINs for reichelt and Generic WebURL 2026-02-08 15:43:50 +01:00
Jan Böhmer
fd76ca12fc Allow to import GTIN from info providers 2026-02-08 15:32:35 +01:00
Jan Böhmer
57c8368b5e Allow to edit the GTIN property of a part and validate the GTIN 2026-02-08 14:44:56 +01:00
Jan Böhmer
7fd7697c02 Added GTIN fields and others to DB 2026-02-08 14:17:58 +01:00
293 changed files with 4189 additions and 1920 deletions

View File

@@ -12,7 +12,7 @@ opcache.max_accelerated_files = 20000
opcache.memory_consumption = 256
opcache.enable_file_override = 1
memory_limit = 256M
memory_limit = 512M
upload_max_filesize=256M
post_max_size=300M
post_max_size=300M

View File

@@ -1,4 +1,3 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-symfony
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,13 +76,85 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=jbtronics/part-db1,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
jbtronics/part-db1
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'jbtronics/part-db1@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect jbtronics/part-db1:${{ steps.docker_meta.outputs.version }}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-server
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,14 +76,86 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile-frankenphp
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=partdborg/part-db,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
partdborg/part-db
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'partdborg/part-db@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect partdborg/part-db:${{ steps.docker_meta.outputs.version }}

View File

@@ -1,15 +1,75 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=debian:bookworm-slim
ARG PHP_VERSION=8.4
ARG NODE_VERSION=22
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# Base stage for PHP
FROM ${BASE_IMAGE} AS base
ARG PHP_VERSION
ARG TARGETARCH
# Install needed dependencies for PHP build
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \
# libpng-dev libjpeg-dev libfreetype6-dev gnupg zip libzip-dev libjpeg62-turbo-dev libonig-dev libxslt-dev libwebp-dev vim \
# && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get -y install \
# Use BuildKit cache mounts for apt in base stage
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
apt-transport-https \
lsb-release \
ca-certificates \
@@ -39,19 +99,10 @@ RUN apt-get update && apt-get -y install \
gpg \
sudo \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* \
# Create workdir and set permissions if directory does not exists
&& mkdir -p /var/www/html \
&& chown -R www-data:www-data /var/www/html \
# delete the "index.html" that installing Apache drops in here
&& rm -rvf /var/www/html/*
# Install node and yarn
RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && apt-get install -y \
nodejs \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* && \
npm install -g yarn
# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@@ -65,14 +116,12 @@ ENV APACHE_ENVVARS=$APACHE_CONFDIR/envvars
# : ${APACHE_RUN_USER:=www-data}
# export APACHE_RUN_USER
# so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \
set -eux; . "$APACHE_ENVVARS"; \
\
# logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" && \
set -eux; . "$APACHE_ENVVARS" && \
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log" && \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR"
# ---
@@ -141,7 +190,6 @@ COPY --chown=www-data:www-data . .
# Setup apache2
RUN a2dissite 000-default.conf && \
a2ensite symfony.conf && \
# Enable php-fpm
a2enmod proxy_fcgi setenvif && \
a2enconf php${PHP_VERSION}-fpm && \
a2enconf docker-php && \
@@ -149,12 +197,13 @@ RUN a2dissite 000-default.conf && \
# Install composer and yarn dependencies for Part-DB
USER www-data
RUN composer install -a --no-dev && \
# Use BuildKit cache for Composer when running as www-data by setting COMPOSER_CACHE_DIR
RUN --mount=type=cache,id=composer-cache,target=/tmp/.composer-cache \
COMPOSER_CACHE_DIR=/tmp/.composer-cache composer install -a --no-dev && \
composer clear-cache
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -166,10 +215,12 @@ USER root
RUN sed -i "s/PHP_VERSION/${PHP_VERSION}/g" ./.docker/partdb-entrypoint.sh
# Copy entrypoint and apache2-foreground to /usr/local/bin and make it executable
RUN install ./.docker/partdb-entrypoint.sh /usr/local/bin && \
install ./.docker/apache2-foreground /usr/local/bin
# Convert CRLF -> LF and install entrypoint scripts with executable mode
RUN sed -i 's/\r$//' ./.docker/partdb-entrypoint.sh ./.docker/apache2-foreground && \
install -m 0755 ./.docker/partdb-entrypoint.sh /usr/local/bin/ && \
install -m 0755 ./.docker/apache2-foreground /usr/local/bin/
ENTRYPOINT ["partdb-entrypoint.sh"]
CMD ["apache2-foreground"]
CMD ["/usr/local/bin/apache2-foreground"]
# https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
STOPSIGNAL SIGWINCH

View File

@@ -1,6 +1,72 @@
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG NODE_VERSION=22
RUN apt-get update && apt-get -y install \
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
curl \
ca-certificates \
mariadb-client \
@@ -13,24 +79,6 @@ RUN apt-get update && apt-get -y install \
zip \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
RUN set -eux; \
# Run NodeSource setup script
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
\
# Install Node.js
apt-get update; \
apt-get install -y --no-install-recommends \
nodejs; \
\
# Cleanup
apt-get -y autoremove; \
apt-get clean autoclean; \
rm -rf /var/lib/apt/lists/*; \
\
# Install Yarn via npm
npm install -g yarn
# Install PHP
RUN set -eux; \
install-php-extensions \
@@ -76,14 +124,11 @@ COPY --link . ./
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -102,8 +147,8 @@ VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
# See https://caddyserver.com/docs/conventions#file-locations for details
ENV XDG_CONFIG_HOME /config
ENV XDG_DATA_HOME /data
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
EXPOSE 80
EXPOSE 443

View File

@@ -1 +1 @@
2.6.0
2.7.0

View File

@@ -74,15 +74,33 @@ export default class extends Controller {
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
let ret = null;
//Insert new html after the last child element
//If the table has a tbody, insert it there
//Afterwards return the newly created row
if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
return targetTable.tBodies[0].lastElementChild;
ret = targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr);
return targetTable.lastElementChild;
ret = targetTable.lastElementChild;
}
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
this.focusNumberInput(ret);
return ret;
}
focusNumberInput(element) {
const fields = element.querySelectorAll("input[type=number]");
//Focus the first available number input field to open the numeric keyboard on mobile devices
if(fields.length > 0) {
fields[0].focus();
}
}

View File

@@ -0,0 +1,27 @@
import {Controller} from "@hotwired/stimulus";
import {Modal} from "bootstrap";
export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
}
_handleModalOpen(event) {
// Button that triggered the modal
const button = event.relatedTarget;
const amountInput = this.element.querySelector('input[name="amount"]');
// Extract info from button attributes
const lotID = button.getAttribute('data-lot-id');
const lotAmount = button.getAttribute('data-lot-amount');
//Find the expected amount field and set the value to the lot amount
const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount');
expectedAmountInput.textContent = lotAmount;
//Set the action and lotID inputs in the form
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
}
}

View File

@@ -5,6 +5,7 @@ export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
this.element.addEventListener('shown.bs.modal', event => this._handleModalShown(event));
}
_handleModalOpen(event) {
@@ -61,4 +62,8 @@ export default class extends Controller
amountInput.setAttribute('max', lotAmount);
}
}
_handleModalShown(event) {
this.element.querySelector('input[name="amount"]').focus();
}
}

View File

@@ -56,7 +56,8 @@ class TristateHelper {
document.addEventListener("turbo:load", listener);
document.addEventListener("turbo:render", listener);
document.addEventListener("collection:elementAdded", listener);
}
}
export default new TristateHelper();
export default new TristateHelper();

View File

@@ -17,7 +17,7 @@
"api-platform/json-api": "^4.0.0",
"api-platform/symfony": "^4.0.0",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.13.1",
"brick/math": "^0.14.8",
"brick/schema": "^0.2.0",
"composer/ca-bundle": "^1.5",
"composer/package-versions-deprecated": "^1.11.99.5",
@@ -28,7 +28,7 @@
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^3.1.2",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"hshn/base64-encoded-file": "^6.0",
"jbtronics/2fa-webauthn": "^3.0.0",
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
"jbtronics/settings-bundle": "^3.0.0",
@@ -45,7 +45,6 @@
"nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1",
"omines/datatables-bundle": "^0.10.0",
"paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0",
"part-db/swap-bundle": "^6.0.0",
"phpoffice/phpspreadsheet": "^5.0.0",
@@ -70,7 +69,7 @@
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^3.1",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
@@ -88,8 +87,9 @@
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^v2.0.1",
"symfony/yaml": "7.4.*",
"symplify/easy-coding-standard": "^12.5.20",
"symplify/easy-coding-standard": "^13.0",
"tecnickcom/tc-lib-barcode": "^2.1.4",
"tiendanube/gtinvalidation": "^1.0",
"twig/cssinliner-extra": "^3.0",
"twig/extra-bundle": "^3.8",
"twig/html-extra": "^3.8",
@@ -128,7 +128,7 @@
},
"suggest": {
"ext-bcmath": "Used to improve price calculation performance",
"ext-gmp": "Used to improve price calculation performanice"
"ext-gmp": "Used to improve price calculation performance"
},
"config": {
"preferred-install": {

633
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,12 +20,14 @@
declare(strict_types=1);
use Symfony\Config\DoctrineConfig;
/**
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
*/
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
return static function(DoctrineConfig $doctrine) {
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
$doctrine->orm()->enableNativeLazyObjects(true);

View File

@@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
move:
label: "perm.parts_stock.move"
apiTokenRole: ROLE_API_EDIT
stocktake:
label: "perm.parts_stock.stocktake"
apiTokenRole: ROLE_API_EDIT
storelocations: &PART_CONTAINING

View File

@@ -1387,7 +1387,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* bubble?: bool|Param, // Default: true
* interactive_only?: bool|Param, // Default: false
* app_name?: scalar|Param|null, // Default: null
* fill_extra_context?: bool|Param, // Default: false
* include_stacktraces?: bool|Param, // Default: false
* process_psr_3_messages?: array{
* enabled?: bool|Param|null, // Default: null
@@ -1407,7 +1406,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* activation_strategy?: scalar|Param|null, // Default: null
* stop_buffering?: bool|Param, // Default: true
* passthru_level?: scalar|Param|null, // Default: null
* excluded_404s?: list<scalar|Param|null>,
* excluded_http_codes?: list<array{ // Default: []
* code?: scalar|Param|null,
* urls?: list<scalar|Param|null>,
@@ -1421,9 +1419,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* url?: scalar|Param|null,
* exchange?: scalar|Param|null,
* exchange_name?: scalar|Param|null, // Default: "log"
* room?: scalar|Param|null,
* message_format?: scalar|Param|null, // Default: "text"
* api_version?: scalar|Param|null, // Default: null
* channel?: scalar|Param|null, // Default: null
* bot_name?: scalar|Param|null, // Default: "Monolog"
* use_attachment?: scalar|Param|null, // Default: true
@@ -1432,9 +1427,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* icon_emoji?: scalar|Param|null, // Default: null
* webhook_url?: scalar|Param|null,
* exclude_fields?: list<scalar|Param|null>,
* team?: scalar|Param|null,
* notify?: scalar|Param|null, // Default: false
* nickname?: scalar|Param|null, // Default: "Monolog"
* token?: scalar|Param|null,
* region?: scalar|Param|null,
* source?: scalar|Param|null,
@@ -1452,12 +1444,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* store?: scalar|Param|null, // Default: null
* connection_timeout?: scalar|Param|null,
* persistent?: bool|Param,
* dsn?: scalar|Param|null,
* hub_id?: scalar|Param|null, // Default: null
* client_id?: scalar|Param|null, // Default: null
* auto_log_stacks?: scalar|Param|null, // Default: false
* release?: scalar|Param|null, // Default: null
* environment?: scalar|Param|null, // Default: null
* message_type?: scalar|Param|null, // Default: 0
* parse_mode?: scalar|Param|null, // Default: null
* disable_webpage_preview?: bool|Param|null, // Default: null
@@ -1467,7 +1453,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* topic?: int|Param, // Default: null
* factor?: int|Param, // Default: 1
* tags?: list<scalar|Param|null>,
* console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead.
* console_formatter_options?: mixed, // Default: []
* formatter?: scalar|Param|null,
* nested?: bool|Param, // Default: false
@@ -1478,15 +1463,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* chunk_size?: scalar|Param|null, // Default: 1420
* encoder?: "json"|"compressed_json"|Param,
* },
* mongo?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 27017
* user?: scalar|Param|null,
* pass?: scalar|Param|null,
* database?: scalar|Param|null, // Default: "monolog"
* collection?: scalar|Param|null, // Default: "logs"
* },
* mongodb?: string|array{
* id?: scalar|Param|null, // ID of a MongoDB\Client service
* uri?: scalar|Param|null,
@@ -1529,7 +1505,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* id: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* lazy?: bool|Param, // Default: true
* verbosity_levels?: array{
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"

View File

@@ -91,18 +91,20 @@ in [official documentation](https://twig.symfony.com/doc/3.x/).
Twig allows you for much more complex and dynamic label generation. You can use loops, conditions, and functions to create
the label content and you can access almost all data Part-DB offers. The label templates are evaluated in a special sandboxed environment,
where only certain operations are allowed. Only read access to entities is allowed. However as it circumvents Part-DB normal permission system,
where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system,
the twig mode is only available to users with the "Twig mode" permission.
It is useful to use the HTML embed feature of the editor, to have a block where you can write the twig code without worrying about the WYSIWYG editor messing with your code.
The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` (
or `{% raw %}{{ variable.property }}{% endraw %}`):
| Variable name | Description |
|--------------------------------------------|--------------------------------------------------------------------------------------|
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ user }}{% endraw %}` | The current logged in user. Null if you are not logged in |
| `{% raw %}{{ install_title }}{% endraw %}` | The name of the current Part-DB instance (similar to [[INSTALL_NAME]] placeholder). |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated ) |
| `{% raw %}{{ last_page }}{% endraw %}` | The page number of the last element. Equals the number of all pages / element labels |
| `{% raw %}{{ paper_width }}{% endraw %}` | The width of the label paper in mm |
| `{% raw %}{{ paper_height }}{% endraw %}` | The height of the label paper in mm |
@@ -236,12 +238,18 @@ certain data:
#### Functions
| Function name | Description |
|----------------------------------------------|-----------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| Function name | Description |
|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| `associated_parts(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. Only the directly associated parts are returned |
| `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) |
| `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities |
| `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) |
| `type_label(element)` | Get the name of the type of an element (e.g. "Part", "Storage location", etc.) |
| `type_label_p(element)` | Get the name of the type of an element in plural form (e.g. "Parts", "Storage locations", etc.) |
### Filters
@@ -285,5 +293,5 @@ If you want to use a different (more beautiful) font, you can use the [custom fo
feature.
There is the [Noto](https://www.google.com/get/noto/) font family from Google, which supports a lot of languages and is
available in different styles (regular, bold, italic, bold-italic).
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20260208131116 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add GTIN fields, allowed targets for attachment types and last stocktake date for part lots and add include_vat field for price details.';
}
public function mySQLUp(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL');
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat TINYINT DEFAULT NULL');
}
public function mySQLDown(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE `attachment_types` DROP allowed_targets');
$this->addSql('DROP INDEX parts_idx_gtin ON `parts`');
$this->addSql('ALTER TABLE `parts` DROP gtin');
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
$this->addSql('ALTER TABLE `orderdetails` DROP prices_includes_vat');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE attachment_types ADD COLUMN allowed_targets CLOB DEFAULT NULL');
$this->addSql('ALTER TABLE part_lots ADD COLUMN last_stocktake_at DATETIME DEFAULT NULL');
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts');
$this->addSql('DROP TABLE parts');
$this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, gtin VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE INDEX parts_idx_name ON parts (name)');
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)');
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
$this->addSql('ALTER TABLE orderdetails ADD COLUMN prices_includes_vat BOOLEAN DEFAULT NULL');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM "attachment_types"');
$this->addSql('DROP TABLE "attachment_types"');
$this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, filetype_filter CLOB NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "attachment_types" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM __temp__attachment_types');
$this->addSql('DROP TABLE __temp__attachment_types');
$this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)');
$this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)');
$this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)');
$this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT 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, vendor_barcode VARCHAR(255) DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT 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, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner 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)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"');
$this->addSql('DROP TABLE "parts"');
$this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)');
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)');
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)');
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)');
$this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)');
$this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)');
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM "orderdetails"');
$this->addSql('DROP TABLE "orderdetails"');
$this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "orderdetails" (id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier) SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM __temp__orderdetails');
$this->addSql('DROP TABLE __temp__orderdetails');
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)');
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)');
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat BOOLEAN DEFAULT NULL');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE "attachment_types" DROP allowed_targets');
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
$this->addSql('DROP INDEX parts_idx_gtin');
$this->addSql('ALTER TABLE "parts" DROP gtin');
$this->addSql('ALTER TABLE "orderdetails" DROP prices_includes_vat');
}
}

View File

@@ -18,7 +18,7 @@ use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
return RectorConfig::configure()
->withComposerBased(phpunit: true)
->withComposerBased(phpunit: true, symfony: true)
->withSymfonyContainerPhp(__DIR__ . '/tests/symfony-container.php')
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml')
@@ -36,8 +36,6 @@ return RectorConfig::configure()
PHPUnitSetList::PHPUNIT_90,
PHPUnitSetList::PHPUNIT_110,
PHPUnitSetList::PHPUNIT_CODE_QUALITY,
])
->withRules([
@@ -59,6 +57,9 @@ return RectorConfig::configure()
PreferPHPUnitThisCallRector::class,
//Do not replace 'GET' with class constant,
LiteralGetToRequestClassConstantRector::class,
//Do not move help text of commands to the command class, as we want to keep the help text in the command definition for better readability
\Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector::class
])
//Do not apply rules to Symfony own files
@@ -67,6 +68,7 @@ return RectorConfig::configure()
__DIR__ . '/src/Kernel.php',
__DIR__ . '/config/preload.php',
__DIR__ . '/config/bundles.php',
__DIR__ . '/config/reference.php'
])
;

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\RequestBody;
use ApiPlatform\OpenApi\Model\Response;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\State\LabelGenerationProcessor;
use App\Validator\Constraints\Misc\ValidRange;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for generating PDF labels for parts, part lots, or storage locations.
* This endpoint allows generating labels using saved label profiles.
*/
#[ApiResource(
uriTemplate: '/labels/generate',
description: 'Generate PDF labels for parts, part lots, or storage locations using label profiles.',
operations: [
new Post(
inputFormats: ['json' => ['application/json']],
outputFormats: [],
openapi: new Operation(
responses: [
"200" => new Response(description: "PDF file containing the generated labels"),
],
summary: 'Generate PDF labels',
description: 'Generate PDF labels for one or more elements using a label profile. Returns a PDF file.',
requestBody: new RequestBody(
description: 'Label generation request',
required: true,
),
),
)
],
processor: LabelGenerationProcessor::class,
)]
class LabelGenerationRequest
{
/**
* @var int The ID of the label profile to use for generation
*/
#[Assert\NotBlank(message: 'Profile ID is required')]
#[Assert\Positive(message: 'Profile ID must be a positive integer')]
public int $profileId = 0;
/**
* @var string Comma-separated list of element IDs or ranges (e.g., "1,2,5-10,15")
*/
#[Assert\NotBlank(message: 'Element IDs are required')]
#[ValidRange()]
#[ApiProperty(example: "1,2,5-10,15")]
public string $elementIds = '';
/**
* @var LabelSupportedElement|null Optional: Override the element type. If not provided, uses profile's default.
*/
public ?LabelSupportedElement $elementType = null;
}

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\DataTables\LogDataTable;
use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Parts\Category;
@@ -54,12 +55,14 @@ use Exception;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
@@ -149,7 +152,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
@@ -170,7 +173,7 @@ final class PartController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token');
}
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
throw $this->createNotFoundException('Bulk import job not found');
}
@@ -336,7 +339,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
@@ -463,6 +466,54 @@ final class PartController extends AbstractController
);
}
#[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])]
#[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')]
public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper,
Request $request,
): Response
{
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
//Check that the user is allowed to stocktake the partlot
$this->denyAccessUnlessGranted('stocktake', $partLot);
if (!$partLot instanceof PartLot) {
throw new \RuntimeException('Part lot not found!');
}
//Ensure that the partlot belongs to the part
if ($partLot->getPart() !== $part) {
throw new \RuntimeException("The origin partlot does not belong to the part!");
}
$actualAmount = (float) $request->request->get('actual_amount');
$comment = $request->request->get('comment');
$timestamp = null;
$timestamp_str = $request->request->getString('timestamp', '');
//Try to parse the timestamp
if ($timestamp_str !== '') {
$timestamp = new DateTime($timestamp_str);
}
$withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp);
//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!");
}
//Save the changes to the DB
$em->flush();
$this->addFlash('success', 'part.withdraw.success');
//If a redirect was passed, then redirect there
if ($request->request->get('_redirect')) {
return $this->redirect($request->request->get('_redirect'));
}
//Otherwise just redirect to the part page
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
{

View File

@@ -147,10 +147,7 @@ class SecurityController extends AbstractController
'label' => 'user.settings.pw_confirm.label',
],
'invalid_message' => 'password_must_match',
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
]);
$builder->add('submit', SubmitType::class, [

View File

@@ -295,10 +295,7 @@ class UserSettingsController extends AbstractController
'autocomplete' => 'new-password',
],
],
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('submit', SubmitType::class, [
'label' => 'save',

View File

@@ -66,6 +66,7 @@ class PartFilter implements FilterInterface
public readonly BooleanConstraint $favorite;
public readonly BooleanConstraint $needsReview;
public readonly NumberConstraint $mass;
public readonly TextConstraint $gtin;
public readonly DateTimeConstraint $lastModified;
public readonly DateTimeConstraint $addedDate;
public readonly EntityConstraint $category;
@@ -132,6 +133,7 @@ class PartFilter implements FilterInterface
$this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit');
$this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState');
$this->mass = new NumberConstraint('part.mass');
$this->gtin = new TextConstraint('part.gtin');
$this->dbId = new IntConstraint('part.id');
$this->ipn = new TextConstraint('part.ipn');
$this->addedDate = new DateTimeConstraint('part.addedDate');

View File

@@ -160,7 +160,7 @@ class PartSearchFilter implements FilterInterface
if ($search_dbId) {
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
\Doctrine\DBAL\ParameterType::INTEGER);
ParameterType::INTEGER);
}
//Guard condition

View File

@@ -47,6 +47,7 @@ use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Column\TextColumn;
@@ -218,6 +219,10 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.mass'),
'unit' => 'g'
])
->add('gtin', TextColumn::class, [
'label' => $this->translator->trans('part.table.gtin'),
'orderField' => 'NATSORT(part.gtin)'
])
->add('tags', TagsColumn::class, [
'label' => $this->translator->trans('part.table.tags'),
])
@@ -329,6 +334,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('orderdetails')
->addSelect('attachments')
->addSelect('storelocations')
->addSelect('projectBomEntries')
->from(Part::class, 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
@@ -343,6 +349,7 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.partCustomState', 'partCustomState')
->leftJoin('part.parameters', 'parameters')
->leftJoin('part.project_bom_entries', 'projectBomEntries')
->where('part.id IN (:ids)')
->setParameter('ids', $ids)
@@ -360,7 +367,12 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('attachments')
->addGroupBy('partUnit')
->addGroupBy('partCustomState')
->addGroupBy('parameters');
->addGroupBy('parameters')
->addGroupBy('projectBomEntries')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');

View File

@@ -97,7 +97,7 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
@@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
* @phpstan-var class-string<T>
*/
protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
/**
* @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.

View File

@@ -134,6 +134,17 @@ class AttachmentType extends AbstractStructuralDBElement
#[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)]
protected Collection $attachments_with_type;
/**
* @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names
*/
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
protected ?array $allowed_targets = null;
/**
* @var class-string<Attachment>[]|null
*/
protected ?array $allowed_targets_parsed_cache = null;
#[Groups(['attachment_type:read'])]
protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['attachment_type:read'])]
@@ -184,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement
return $this;
}
/**
* Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions.
* @return class-string<Attachment>[]|null
*/
public function getAllowedTargets(): ?array
{
//Use cached value if available
if ($this->allowed_targets_parsed_cache !== null) {
return $this->allowed_targets_parsed_cache;
}
if (empty($this->allowed_targets)) {
return null;
}
$tmp = [];
foreach ($this->allowed_targets as $target) {
if (isset(Attachment::ORM_DISCRIMINATOR_MAP[$target])) {
$tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target];
}
//Otherwise ignore the entry, as it is invalid
}
//Cache the parsed value
$this->allowed_targets_parsed_cache = $tmp;
return $tmp;
}
/**
* Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions.
* @param class-string<Attachment>[]|null $allowed_targets
* @return $this
*/
public function setAllowedTargets(?array $allowed_targets): self
{
if ($allowed_targets === null) {
$this->allowed_targets = null;
} else {
$tmp = [];
foreach ($allowed_targets as $target) {
$discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true);
if ($discriminator !== false) {
$tmp[] = $discriminator;
} else {
throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass.");
}
}
$this->allowed_targets = $tmp;
}
//Reset the cache
$this->allowed_targets_parsed_cache = null;
return $this;
}
/**
* Checks if this attachment type is allowed for the given attachment target.
* @param Attachment|string $attachment
* @return bool
*/
public function isAllowedForTarget(Attachment|string $attachment): bool
{
//If no restrictions are set, allow all targets
if ($this->getAllowedTargets() === null) {
return true;
}
//Iterate over all allowed targets and check if the attachment is an instance of any of them
foreach ($this->getAllowedTargets() as $allowed_target) {
if (is_a($attachment, $allowed_target, true)) {
return true;
}
}
return false;
}
}

View File

@@ -41,6 +41,12 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\OpenApi\Model\Operation;
use Doctrine\Common\Collections\Criteria;
use App\Entity\Attachments\Attachment;
use App\Repository\LabelProfileRepository;
@@ -58,6 +64,22 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* @extends AttachmentContainingDBElement<LabelAttachment>
*/
#[ApiResource(
operations: [
new Get(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('read', object)",
openapi: new Operation(summary: 'Get a label profile by ID')
),
new GetCollection(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('@labels.create_labels')",
openapi: new Operation(summary: 'List all available label profiles')
),
],
paginationEnabled: false,
)]
#[ApiFilter(SearchFilter::class, properties: ['options.supported_element' => 'exact', 'show_in_dropdown' => 'exact'])]
#[UniqueEntity(['name', 'options.supported_element'])]
#[ORM\Entity(repositoryClass: LabelProfileRepository::class)]
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
@@ -80,20 +102,21 @@ class LabelProfile extends AttachmentContainingDBElement
*/
#[Assert\Valid]
#[ORM\Embedded(class: 'LabelOptions')]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected LabelOptions $options;
/**
* @var string The comment info for this element
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected string $comment = '';
/**
* @var bool determines, if this label profile should be shown in the dropdown quick menu
*/
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected bool $show_in_dropdown = true;
public function __construct()

View File

@@ -28,6 +28,8 @@ enum PartStockChangeType: string
case WITHDRAW = "withdraw";
case MOVE = "move";
case STOCKTAKE = "stock_take";
/**
* Converts the type to a short representation usable in the extra field of the log entry.
* @return string
@@ -38,6 +40,7 @@ enum PartStockChangeType: string
self::ADD => 'a',
self::WITHDRAW => 'w',
self::MOVE => 'm',
self::STOCKTAKE => 's',
};
}
@@ -52,6 +55,7 @@ enum PartStockChangeType: string
'a' => self::ADD,
'w' => self::WITHDRAW,
'm' => self::MOVE,
's' => self::STOCKTAKE,
default => throw new \InvalidArgumentException("Invalid short type: $value"),
};
}

View File

@@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
}
public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
{
return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
}
/**
* Returns the instock change type of this entry
* @return PartStockChangeType

View File

@@ -80,6 +80,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
#[ApiResource(
operations: [
new Get(normalizationContext: [

View File

@@ -171,6 +171,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
#[Length(max: 255)]
protected ?string $user_barcode = null;
/**
* @var \DateTimeImmutable|null The date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
*/
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: true)]
#[Year2038BugWorkaround]
protected ?\DateTimeImmutable $last_stocktake_at = null;
public function __clone()
{
if ($this->id) {
@@ -391,6 +399,26 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
return $this;
}
/**
* Returns the date when the last stocktake was performed for this part lot. Returns null, if no stocktake was performed yet.
* @return \DateTimeImmutable|null
*/
public function getLastStocktakeAt(): ?\DateTimeImmutable
{
return $this->last_stocktake_at;
}
/**
* Sets the date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
* @param \DateTimeImmutable|null $last_stocktake_at
* @return $this
*/
public function setLastStocktakeAt(?\DateTimeImmutable $last_stocktake_at): self
{
$this->last_stocktake_at = $last_stocktake_at;
return $this;
}
#[Assert\Callback]

View File

@@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits;
use App\Entity\Parts\InfoProviderReference;
use App\Entity\Parts\PartCustomState;
use App\Validator\Constraints\ValidGTIN;
use Doctrine\DBAL\Types\Types;
use App\Entity\Parts\Part;
use Doctrine\ORM\Mapping as ORM;
@@ -84,6 +85,14 @@ trait AdvancedPropertyTrait
#[ORM\JoinColumn(name: 'id_part_custom_state')]
protected ?PartCustomState $partCustomState = null;
/**
* @var string|null The GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code
*/
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[ValidGTIN]
protected ?string $gtin = null;
/**
* Checks if this part is marked, for that it needs further review.
*/
@@ -211,4 +220,26 @@ trait AdvancedPropertyTrait
return $this;
}
/**
* Gets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
* Returns null if no GTIN is set.
*/
public function getGtin(): ?string
{
return $this->gtin;
}
/**
* Sets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
*
* @param string|null $gtin The new GTIN of the part
*
* @return $this
*/
public function setGtin(?string $gtin): self
{
$this->gtin = $gtin;
return $this;
}
}

View File

@@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Length;
@@ -147,6 +148,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
#[ORM\JoinColumn(name: 'id_supplier')]
protected ?Supplier $supplier = null;
/**
* @var bool|null Whether the prices includes VAT or not. Null means, that it is not specified, if the prices includes VAT or not.
*/
#[ORM\Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
protected ?bool $prices_includes_vat = null;
public function __construct()
{
$this->pricedetails = new ArrayCollection();
@@ -388,6 +396,28 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this;
}
/**
* Checks if the prices of this orderdetail include VAT. Null means, that it is not specified, if the prices includes
* VAT or not.
* @return bool|null
*/
public function getPricesIncludesVAT(): ?bool
{
return $this->prices_includes_vat;
}
/**
* Sets whether the prices of this orderdetail include VAT.
* @param bool|null $includesVat
* @return $this
*/
public function setPricesIncludesVAT(?bool $includesVat): self
{
$this->prices_includes_vat = $includesVat;
return $this;
}
public function getName(): string
{
return $this->getSupplierPartNr();

View File

@@ -121,6 +121,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
#[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
protected ?Orderdetail $orderdetail = null;
public function __construct()
{
$this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION);
@@ -264,6 +266,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
return $this->currency?->getIsoCode();
}
/**
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
* @return bool|null
*/
public function getIncludesVat(): ?bool
{
return $this->orderdetail?->getPricesIncludesVAT();
}
/********************************************************************************
*
* Setters

View File

@@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable
/**
* The current schema version of the permission data
*/
public const CURRENT_SCHEMA_VERSION = 3;
public const CURRENT_SCHEMA_VERSION = 4;
/**
* Creates a new Permission Data Instance using the given data.

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;

View File

@@ -42,15 +42,14 @@ declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
use Twig\Error\Error;
class TwigModeException extends RuntimeException
{
private const PROJECT_PATH = __DIR__ . '/../../';
public function __construct(?Error $previous = null)
public function __construct(?\Throwable $previous = null)
{
parent::__construct($previous->getMessage(), 0, $previous);
parent::__construct($previous?->getMessage() ?? "Unknown message", 0, $previous);
}
/**

View File

@@ -22,17 +22,23 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Services\ElementTypeNameGenerator;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Services\Attachments\FileTypeFilterTools;
use App\Services\LogSystem\EventCommentNeededHelper;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Translation\StaticMessage;
class AttachmentTypeAdminForm extends BaseEntityAdminForm
{
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper)
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
{
parent::__construct($security, $eventCommentNeededHelper);
}
@@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm
{
$is_new = null === $entity->getID();
$choiceLabel = function (string $class) {
if (!is_a($class, Attachment::class, true)) {
return $class;
}
return new StaticMessage($this->elementTypeNameGenerator->typeLabelPlural($class::ALLOWED_ELEMENT_CLASS));
};
$builder->add('allowed_targets', ChoiceType::class, [
'required' => false,
'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP),
'choice_label' => $choiceLabel,
'preferred_choices' => [PartAttachment::class, ProjectAttachment::class],
'label' => 'attachment_type.edit.allowed_targets',
'help' => 'attachment_type.edit.allowed_targets.help',
'multiple' => true,
]);
$builder->add('filetype_filter', TextType::class, [
'required' => false,
'label' => 'attachment_type.edit.filetype_filter',

View File

@@ -71,6 +71,7 @@ class BaseEntityAdminForm extends AbstractType
'label' => 'name.label',
'attr' => [
'placeholder' => 'part.name.placeholder',
'autofocus' => $is_new,
],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form;
use App\Form\Type\AttachmentTypeType;
use App\Settings\SystemSettings\AttachmentsSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\Attachment;
@@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType
'required' => false,
'empty_data' => '',
])
->add('attachment_type', StructuralEntityType::class, [
->add('attachment_type', AttachmentTypeType::class, [
'label' => 'attachment.edit.attachment_type',
'class' => AttachmentType::class,
'disable_not_selectable' => true,
'attachment_filter_class' => $options['data_class'] ?? null,
'allow_add' => $this->security->isGranted('@attachment_types.create'),
]);
@@ -121,9 +122,7 @@ class AttachmentFormType extends AbstractType
],
'constraints' => [
//new AllowedFileExtension(),
new File([
'maxSize' => $options['max_file_size'],
]),
new File(maxSize: $options['max_file_size']),
],
]);

View File

@@ -135,6 +135,10 @@ class PartFilterType extends AbstractType
'min' => 0,
]);
$builder->add('gtin', TextConstraintType::class, [
'label' => 'part.gtin',
]);
$builder->add('measurementUnit', StructuralEntityConstraintType::class, [
'label' => 'part.edit.partUnit',
'entity_class' => MeasurementUnit::class

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
@@ -61,7 +62,7 @@ class FieldToProviderMappingType extends AbstractType
'style' => 'width: 80px;'
],
'constraints' => [
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
new Range(min: 1, max: 10),
],
]);
}

View File

@@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
},
]);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\Part;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Supplier;
@@ -73,6 +74,11 @@ class OrderdetailType extends AbstractType
'label' => 'orderdetails.edit.obsolete',
]);
$builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [
'required' => false,
'label' => 'orderdetails.edit.prices_includes_vat',
]);
//Add pricedetails after we know the data, so we can set the default currency
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
/** @var Orderdetail $orderdetail */

View File

@@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Services\LogSystem\EventCommentType;
use App\Settings\MiscSettings\IpnSuggestSettings;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -63,6 +64,7 @@ class PartBaseType extends AbstractType
protected UrlGeneratorInterface $urlGenerator,
protected EventCommentNeededHelper $event_comment_needed_helper,
protected IpnSuggestSettings $ipnSuggestSettings,
private readonly LocalizationSettings $localizationSettings,
) {
}
@@ -115,6 +117,7 @@ class PartBaseType extends AbstractType
'label' => 'part.edit.name',
'attr' => [
'placeholder' => 'part.edit.name.placeholder',
'autofocus' => $new_part,
],
])
->add('description', RichTextEditorType::class, [
@@ -216,7 +219,13 @@ class PartBaseType extends AbstractType
'disable_not_selectable' => true,
'label' => 'part.edit.partCustomState',
])
->add('ipn', TextType::class, $ipnOptions);
->add('ipn', TextType::class, $ipnOptions)
->add('gtin', TextType::class, [
'required' => false,
'empty_data' => null,
'label' => 'part.gtin',
])
;
//Comment section
$builder->add('comment', RichTextEditorType::class, [
@@ -261,6 +270,9 @@ class PartBaseType extends AbstractType
'entity' => $part,
]);
$orderdetailPrototype = new Orderdetail();
$orderdetailPrototype->setPricesIncludesVAT($this->localizationSettings->pricesIncludeTaxByDefault);
//Orderdetails section
$builder->add('orderdetails', CollectionType::class, [
'entry_type' => OrderdetailType::class,
@@ -269,7 +281,7 @@ class PartBaseType extends AbstractType
'allow_delete' => true,
'label' => false,
'by_reference' => false,
'prototype_data' => new Orderdetail(),
'prototype_data' => $orderdetailPrototype,
'entry_options' => [
'measurement_unit' => $part->getPartUnit(),
],

View File

@@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType;
use App\Form\Type\UserSelectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -110,6 +111,14 @@ class PartLotType extends AbstractType
//Do not remove whitespace chars on the beginning and end of the string
'trim' => false,
]);
$builder->add('last_stocktake_at', DateTimeType::class, [
'label' => 'part_lot.edit.last_stocktake_at',
'widget' => 'single_text',
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
'required' => false,
'empty_data' => null,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\Attachments\AttachmentType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class.
*/
class AttachmentTypeType extends AbstractType
{
public function getParent(): ?string
{
return StructuralEntityType::class;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);
$resolver->setDefault('class', AttachmentType::class);
$resolver->setDefault('choice_filter', function (Options $options) {
if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) {
return static function (?AttachmentType $choice) use ($options) {
return $choice?->isAllowedForTarget($options['attachment_filter_class']);
};
}
return null;
});
}
}

View File

@@ -177,10 +177,7 @@ class UserAdminForm extends AbstractType
'required' => false,
'mapped' => false,
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('need_pw_change', CheckboxType::class, [

View File

@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
'accept' => 'image/*',
],
'constraints' => [
new File([
'maxSize' => '5M',
]),
new File(maxSize: '5M'),
],
])
->add('aboutMe', RichTextEditorType::class, [

View File

@@ -58,13 +58,13 @@ final class PartLotVoter extends Voter
{
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move', 'stocktake'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
if (in_array($attribute, ['withdraw', 'add', 'move', 'stocktake'], true))
{
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);

View File

@@ -59,6 +59,7 @@ class PartMerger implements EntityMergerInterface
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
$this->useOtherValueIfNotEmtpy($target, $other, 'mass');
$this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
$this->useOtherValueIfNotEmtpy($target, $other, 'gtin');
//Merge relations to other entities
$this->useOtherValueIfNotNull($target, $other, 'manufacturer');
@@ -184,4 +185,4 @@ class PartMerger implements EntityMergerInterface
}
}
}
}
}

View File

@@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
@@ -275,7 +277,7 @@ class BOMImporter
$mapped_entries = []; // Collect all mapped entries for validation
// Fetch suppliers once for efficiency
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
$supplierSPNKeys = [];
$suppliersByName = []; // Map supplier names to supplier objects
foreach ($suppliers as $supplier) {
@@ -371,7 +373,7 @@ class BOMImporter
if ($supplier_spn !== null) {
// Query for orderdetails with matching supplier and SPN
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
$orderdetail = $this->entityManager->getRepository(Orderdetail::class)
->findOneBy([
'supplier' => $supplier,
'supplierpartnr' => $supplier_spn,
@@ -535,7 +537,7 @@ class BOMImporter
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
@@ -570,7 +572,7 @@ class BOMImporter
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Helpers\FilenameSanatizer;
@@ -177,7 +178,7 @@ class EntityExporter
$colIndex = 1;
foreach ($columns as $column) {
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$worksheet->setCellValue($cellCoordinate, $column);
$colIndex++;
}
@@ -265,11 +266,14 @@ class EntityExporter
//Sanitize the filename
$filename = FilenameSanatizer::sanitizeFilename($filename);
//Remove percent for fallback
$fallback = str_replace("%", "_", $filename);
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
u($filename)->ascii()->toString(),
$fallback,
);
// Set the content disposition
$response->headers->set('Content-Disposition', $disposition);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\Category;
@@ -419,14 +420,14 @@ class EntityImporter
'worksheet_title' => $worksheet->getTitle()
]);
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
// Read all columns using numeric index
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
$col = Coordinate::stringFromColumnIndex($colIndex);
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\DTOs;
use Doctrine\ORM\Exception\ORMException;
use App\Entity\Parts\Part;
use Doctrine\ORM\EntityManagerInterface;
use Traversable;
@@ -176,7 +177,7 @@ readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate
* @param array $data
* @param EntityManagerInterface $entityManager
* @return BulkSearchResponseDTO
* @throws \Doctrine\ORM\Exception\ORMException
* @throws ORMException
*/
public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO
{

View File

@@ -42,6 +42,7 @@ class PartDetailDTO extends SearchResultDTO
?ManufacturingStatus $manufacturing_status = null,
?string $provider_url = null,
?string $footprint = null,
?string $gtin = null,
public readonly ?string $notes = null,
/** @var FileDTO[]|null */
public readonly ?array $datasheets = null,
@@ -68,6 +69,7 @@ class PartDetailDTO extends SearchResultDTO
manufacturing_status: $manufacturing_status,
provider_url: $provider_url,
footprint: $footprint,
gtin: $gtin
);
}
}

View File

@@ -39,7 +39,9 @@ readonly class PriceDTO
public string $price,
/** @var string The currency of the used ISO code of this price detail */
public ?string $currency_iso_code,
/** @var bool If the price includes tax */
/** @var bool If the price includes tax
* @deprecated Use the prices_include_vat property of the PurchaseInfoDTO instead, as this property is not reliable if there are multiple prices with different values for includes_tax
*/
public ?bool $includes_tax = true,
/** @var float the price related quantity */
public ?float $price_related_quantity = 1.0,

View File

@@ -29,6 +29,9 @@ namespace App\Services\InfoProviderSystem\DTOs;
*/
readonly class PurchaseInfoDTO
{
/** @var bool|null If the prices contain VAT or not. Null if state is unknown. */
public ?bool $prices_include_vat;
public function __construct(
public string $distributor_name,
public string $order_number,
@@ -36,6 +39,7 @@ readonly class PurchaseInfoDTO
public array $prices,
/** @var string|null An url to the product page of the vendor */
public ?string $product_url = null,
?bool $prices_include_vat = null,
)
{
//Ensure that the prices are PriceDTO instances
@@ -44,5 +48,17 @@ readonly class PurchaseInfoDTO
throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances');
}
}
//If no prices_include_vat information is given, try to deduct it from the prices
if ($prices_include_vat === null) {
$vatValues = array_unique(array_map(fn(PriceDTO $price) => $price->includes_tax, $this->prices));
if (count($vatValues) === 1) {
$this->prices_include_vat = $vatValues[0]; //Use the value of the prices if they are all the same
} else {
$this->prices_include_vat = null; //If there are different values for the prices, we cannot determine if the prices include VAT or not
}
} else {
$this->prices_include_vat = $prices_include_vat;
}
}
}

View File

@@ -59,6 +59,8 @@ class SearchResultDTO
public readonly ?string $provider_url = null,
/** @var string|null A footprint representation of the providers page */
public readonly ?string $footprint = null,
/** @var string|null The GTIN / EAN of the part */
public readonly ?string $gtin = null,
)
{
if ($preview_image_url !== null) {
@@ -90,6 +92,7 @@ class SearchResultDTO
'manufacturing_status' => $this->manufacturing_status?->value,
'provider_url' => $this->provider_url,
'footprint' => $this->footprint,
'gtin' => $this->gtin,
];
}
@@ -112,6 +115,7 @@ class SearchResultDTO
manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null,
provider_url: $data['provider_url'] ?? null,
footprint: $data['footprint'] ?? null,
gtin: $data['gtin'] ?? null,
);
}
}

View File

@@ -94,7 +94,6 @@ final class DTOtoEntityConverter
$entity->setPrice($dto->getPriceAsBigDecimal());
$entity->setPriceRelatedQuantity($dto->price_related_quantity);
//Currency TODO
if ($dto->currency_iso_code !== null) {
$entity->setCurrency($this->getCurrency($dto->currency_iso_code));
} else {
@@ -117,6 +116,8 @@ final class DTOtoEntityConverter
$entity->addPricedetail($this->convertPrice($price));
}
$entity->setPricesIncludesVAT($dto->prices_include_vat);
return $entity;
}
@@ -175,6 +176,8 @@ final class DTOtoEntityConverter
$entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET);
$entity->setManufacturerProductURL($dto->manufacturer_product_url ?? '');
$entity->setGtin($dto->gtin);
//Set the provider reference on the part
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));

View File

@@ -82,7 +82,7 @@ final class PartInfoRetriever
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array
{
//Generate key and escape reserved characters from the provider id
$escaped_keyword = urlencode($keyword);
$escaped_keyword = hash('xxh3', $keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
@@ -108,7 +108,7 @@ final class PartInfoRetriever
}
//Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id);
$escaped_part_id = hash('xxh3', $part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
@@ -145,4 +145,4 @@ final class PartInfoRetriever
return $this->dto_to_entity_converter->convertPart($details);
}
}
}

View File

@@ -34,7 +34,7 @@ use App\Settings\InfoProviderSystem\BuerklinSettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class BuerklinProvider implements BatchInfoProviderInterface
class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
{
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
@@ -365,7 +365,7 @@ class BuerklinProvider implements BatchInfoProviderInterface
* - prefers 'zoom' format, then 'product' format, then all others
*
* @param array|null $images
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
* @return FileDTO[]
*/
private function getProductImages(?array $images): array
{
@@ -636,4 +636,35 @@ class BuerklinProvider implements BatchInfoProviderInterface
);
}
public function getHandledDomains(): array
{
return ['buerklin.com'];
}
public function getIDFromURL(string $url): ?string
{
//Inputs:
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
//https://www.buerklin.com/de/p/40F1332/
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
//https://www.buerklin.com/en/p/40F1332/
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
$path = parse_url($url, PHP_URL_PATH);
if (!$path) {
return null;
}
// Ensure it's actually a product URL
if (strpos($path, '/p/') === false) {
return null;
}
$id = basename(rtrim($path, '/'));
return $id !== '' && $id !== 'p' ? $id : null;
}
}

View File

@@ -120,6 +120,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
preview_image_url: $result['image'] ?? null,
provider_url: $this->getProductUrl($result['productId']),
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
gtin: $result['ean'] ?? null,
);
}
@@ -302,6 +303,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
provider_url: $this->getProductUrl($data['shortProductNumber']),
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
gtin: $data['productFullInformation']['eanCode'] ?? null,
notes: $data['productFullInformation']['description'] ?? null,
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
@@ -316,6 +318,8 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
ProviderCapabilities::FOOTPRINT,
ProviderCapabilities::GTIN,
];
}

View File

@@ -227,10 +227,11 @@ class GenericWebProvider implements InfoProviderInterface
mpn: $product->mpn?->toString(),
preview_image_url: $image,
provider_url: $url,
gtin: $product->gtin14?->toString() ?? $product->gtin13?->toString() ?? $product->gtin12?->toString() ?? $product->gtin8?->toString(),
notes: $notes,
parameters: $parameters,
vendor_infos: $vendor_infos,
mass: $mass
mass: $mass,
);
}
@@ -429,7 +430,8 @@ class GenericWebProvider implements InfoProviderInterface
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::PRICE
ProviderCapabilities::PRICE,
ProviderCapabilities::GTIN,
];
}
}

View File

@@ -43,6 +43,9 @@ enum ProviderCapabilities
/** Information about the footprint of a part */
case FOOTPRINT;
/** Provider can provide GTIN for a part */
case GTIN;
/**
* Get the order index for displaying capabilities in a stable order.
* @return int
@@ -55,6 +58,7 @@ enum ProviderCapabilities
self::DATASHEET => 3,
self::PRICE => 4,
self::FOOTPRINT => 5,
self::GTIN => 6,
};
}
@@ -66,6 +70,7 @@ enum ProviderCapabilities
self::PICTURE => 'picture',
self::DATASHEET => 'datasheet',
self::PRICE => 'price',
self::GTIN => 'gtin',
};
}
@@ -77,6 +82,7 @@ enum ProviderCapabilities
self::PICTURE => 'fa-image',
self::DATASHEET => 'fa-file-alt',
self::PRICE => 'fa-money-bill-wave',
self::GTIN => 'fa-barcode',
};
}
}

View File

@@ -84,6 +84,8 @@ class ReicheltProvider implements InfoProviderInterface
$name = $element->filter('meta[itemprop="name"]')->attr('content');
$sku = $element->filter('meta[itemprop="sku"]')->attr('content');
//Try to extract a picture URL:
$pictureURL = $element->filter("div.al_artlogo img")->attr('src');
@@ -95,7 +97,8 @@ class ReicheltProvider implements InfoProviderInterface
category: null,
manufacturer: $sku,
preview_image_url: $pictureURL,
provider_url: $element->filter('a.al_artinfo_link')->attr('href')
provider_url: $element->filter('a.al_artinfo_link')->attr('href'),
);
});
@@ -146,6 +149,15 @@ class ReicheltProvider implements InfoProviderInterface
$priceString = $dom->filter('meta[itemprop="price"]')->attr('content');
$currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR');
$gtin = null;
foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8'] as $gtinType) {
if ($dom->filter("[itemprop=\"$gtinType\"]")->count() > 0) {
$gtin = $dom->filter("[itemprop=\"$gtinType\"]")->innerText();
break;
}
}
//Create purchase info
$purchaseInfo = new PurchaseInfoDTO(
distributor_name: self::DISTRIBUTOR_NAME,
@@ -167,10 +179,11 @@ class ReicheltProvider implements InfoProviderInterface
mpn: $this->parseMPN($dom),
preview_image_url: $json[0]['article_picture'],
provider_url: $productPage,
gtin: $gtin,
notes: $notes,
datasheets: $datasheets,
parameters: $this->parseParameters($dom),
vendor_infos: [$purchaseInfo]
vendor_infos: [$purchaseInfo],
);
}
@@ -273,6 +286,7 @@ class ReicheltProvider implements InfoProviderInterface
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
ProviderCapabilities::GTIN,
];
}
}

View File

@@ -77,6 +77,10 @@ final class BarcodeRedirector
return $this->getURLVendorBarcode($barcodeScan);
}
if ($barcodeScan instanceof GTINBarcodeScanResult) {
return $this->getURLGTINBarcode($barcodeScan);
}
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
}
@@ -111,6 +115,16 @@ final class BarcodeRedirector
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
}
private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string
{
$part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
if (!$part instanceof Part) {
throw new EntityNotFoundException();
}
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
}
/**
* Gets a part from a scan of a Vendor Barcode by filtering for parts
* with the same Info Provider Id or, if that fails, by looking for parts with a

View File

@@ -92,6 +92,9 @@ final class BarcodeScanHelper
if ($type === BarcodeSourceType::EIGP114) {
return $this->parseEIGP114Barcode($input);
}
if ($type === BarcodeSourceType::GTIN) {
return $this->parseGTINBarcode($input);
}
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@@ -117,9 +120,19 @@ final class BarcodeScanHelper
return $result;
}
//If the result is a valid GTIN barcode, we can parse it directly
if (GTINBarcodeScanResult::isValidGTIN($input)) {
return $this->parseGTINBarcode($input);
}
throw new InvalidArgumentException('Unknown barcode');
}
private function parseGTINBarcode(string $input): GTINBarcodeScanResult
{
return new GTINBarcodeScanResult($input);
}
private function parseEIGP114Barcode(string $input): EIGP114BarcodeScanResult
{
return EIGP114BarcodeScanResult::parseFormat06Code($input);

View File

@@ -42,4 +42,9 @@ enum BarcodeSourceType
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
case EIGP114;
}
/**
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
*/
case GTIN;
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\LabelSystem\BarcodeScanner;
use GtinValidation\GtinValidator;
readonly class GTINBarcodeScanResult implements BarcodeScanResultInterface
{
private GtinValidator $validator;
public function __construct(
public string $gtin,
) {
$this->validator = new GtinValidator($this->gtin);
}
public function getDecodedForInfoMode(): array
{
$obj = $this->validator->getGtinObject();
return [
'GTIN' => $this->gtin,
'GTIN type' => $obj->getType(),
'Valid' => $this->validator->isValid() ? 'Yes' : 'No',
];
}
/**
* Checks if the given input is a valid GTIN. This is used to determine whether a scanned barcode should be interpreted as a GTIN or not.
* @param string $input
* @return bool
*/
public static function isValidGTIN(string $input): bool
{
try {
return (new GtinValidator($input))->isValid();
} catch (\Exception $e) {
return false;
}
}
}

View File

@@ -95,7 +95,7 @@ final class LabelHTMLGenerator
'paper_height' => $options->getHeight(),
]
);
} catch (Error $exception) {
} catch (\Throwable $exception) {
throw new TwigModeException($exception);
}
} else {

View File

@@ -70,12 +70,14 @@ use App\Twig\Sandbox\SandboxedLabelExtension;
use App\Twig\TwigCoreExtension;
use InvalidArgumentException;
use Twig\Environment;
use Twig\Extension\AttributeExtension;
use Twig\Extension\SandboxExtension;
use Twig\Extra\Html\HtmlExtension;
use Twig\Extra\Intl\IntlExtension;
use Twig\Extra\Markdown\MarkdownExtension;
use Twig\Extra\String\StringExtension;
use Twig\Loader\ArrayLoader;
use Twig\RuntimeLoader\FactoryRuntimeLoader;
use Twig\Sandbox\SecurityPolicyInterface;
/**
@@ -84,11 +86,11 @@ use Twig\Sandbox\SecurityPolicyInterface;
*/
final class SandboxedTwigFactory
{
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with'];
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'types', 'verbatim', 'with'];
private const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name',
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'first', 'format',
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'find', 'first', 'format',
'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'html_to_markdown', 'join', 'keys',
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'raw', 'number_format',
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'number_format', 'raw',
'reduce', 'replace', 'reverse', 'round', 'slice', 'slug', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title',
'trim', 'u', 'upper', 'url_encode',
@@ -102,16 +104,17 @@ final class SandboxedTwigFactory
];
private const ALLOWED_FUNCTIONS = ['country_names', 'country_timezones', 'currency_names', 'cycle',
'date', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
'template_from_string', 'timezone_names',
'date', 'enum', 'enum_cases', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
'timezone_names',
//Part-DB specific extensions:
//EntityExtension:
'entity_type', 'entity_url',
'entity_type', 'entity_url', 'type_label', 'type_label_plural',
//BarcodeExtension:
'barcode_svg',
//SandboxedLabelExtension
'placeholder',
'associated_parts', 'associated_parts_count', 'associated_parts_r', 'associated_parts_count_r',
];
private const ALLOWED_METHODS = [
@@ -128,7 +131,7 @@ final class SandboxedTwigFactory
'getValueTypical', 'getUnit', 'getValueText', ],
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],
PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation',
'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', 'getVendorBarcode'],
'getPart', 'isInstockUnknown', 'getAmount', 'getOwner', 'getLastStocktakeAt', 'getNeedsRefill', 'getVendorBarcode'],
StorageLocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'],
Supplier::class => ['getShippingCosts', 'getDefaultCurrency'],
Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference',
@@ -139,13 +142,13 @@ final class SandboxedTwigFactory
'getParameters', 'getGroupedParameters',
'isProjectBuildPart', 'getBuiltProject',
'getAssociatedPartsAsOwner', 'getAssociatedPartsAsOther', 'getAssociatedPartsAll',
'getEdaInfo'
'getEdaInfo', 'getGtin'
],
Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'],
Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete',
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl'],
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl', 'getPricesIncludesVAT'],
Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity',
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode'],
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode', 'getIncludesVat'],
InfoProviderReference:: class => ['getProviderKey', 'getProviderId', 'getProviderUrl', 'getLastUpdated', 'isProviderCreated'],
PartAssociation::class => ['getType', 'getComment', 'getOwner', 'getOther', 'getOtherType'],
@@ -186,13 +189,18 @@ final class SandboxedTwigFactory
$twig->addExtension(new StringExtension());
$twig->addExtension(new HtmlExtension());
//Add Part-DB specific extension
$twig->addExtension($this->formatExtension);
$twig->addExtension($this->barcodeExtension);
$twig->addExtension($this->entityExtension);
$twig->addExtension($this->twigCoreExtension);
$twig->addExtension($this->sandboxedLabelExtension);
//Our other extensions are using attributes, we need a bit more work to load them
$dynamicExtensions = [$this->formatExtension, $this->barcodeExtension, $this->entityExtension, $this->twigCoreExtension];
$dynamicExtensionsMap = [];
foreach ($dynamicExtensions as $extension) {
$twig->addExtension(new AttributeExtension($extension::class));
$dynamicExtensionsMap[$extension::class] = static fn () => $extension;
}
$twig->addRuntimeLoader(new FactoryRuntimeLoader($dynamicExtensionsMap));
return $twig;
}

View File

@@ -32,7 +32,7 @@ class LogDiffFormatter
* @param $old_data
* @param $new_data
*/
public function formatDiff($old_data, $new_data): string
public function formatDiff(mixed $old_data, mixed $new_data): string
{
if (is_string($old_data) && is_string($new_data)) {
return $this->diffString($old_data, $new_data);

View File

@@ -197,4 +197,45 @@ final class PartLotWithdrawAddHelper
$this->entityManager->remove($origin);
}
}
/**
* Perform a stocktake for the given part lot, setting the amount to the given actual amount.
* Please note that the changes are not flushed to DB yet, you have to do this yourself
* @param PartLot $lot
* @param float $actualAmount
* @param string|null $comment
* @param \DateTimeInterface|null $action_timestamp
* @return void
*/
public function stocktake(PartLot $lot, float $actualAmount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): void
{
if ($actualAmount < 0) {
throw new \InvalidArgumentException('Actual amount must be non-negative');
}
$part = $lot->getPart();
//Check whether we have to round the amount
if (!$part->useFloatAmount()) {
$actualAmount = round($actualAmount);
}
$oldAmount = $lot->getAmount();
//Clear any unknown status when doing a stocktake, as we now have a known amount
$lot->setInstockUnknown(false);
$lot->setAmount($actualAmount);
if ($action_timestamp) {
$lot->setLastStocktakeAt(\DateTimeImmutable::createFromInterface($action_timestamp));
} else {
$lot->setLastStocktakeAt(new \DateTimeImmutable()); //Use now if no timestamp is given
}
$event = PartStockChangedLogEntry::stocktake($lot, $oldAmount, $lot->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
if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
$this->eventCommentHelper->setMessage($comment);
}
}
}

View File

@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Services\System;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
@@ -334,7 +336,7 @@ readonly class BackupManager
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
if ($platform instanceof AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
$mysqlCmd = 'mysql';
if (isset($params['host'])) {
@@ -361,7 +363,7 @@ readonly class BackupManager
if (!$process->isSuccessful()) {
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
}
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
} elseif ($platform instanceof PostgreSQLPlatform) {
// Use psql command to import
$psqlCmd = 'psql';
if (isset($params['host'])) {

View File

@@ -157,4 +157,20 @@ class PermissionSchemaUpdater
$permissions->setPermissionValue('system', 'show_updates', $new_value);
}
}
private function upgradeSchemaToVersion4(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection
{
$permissions = $holder->getPermissions();
//If the reports.generate permission is not defined yet, set it to the value of reports.read
if (!$permissions->isPermissionSet('parts_stock', 'stocktake')) {
//Set the new permission to true only if both add and withdraw are allowed
$new_value = TrinaryLogicHelper::and(
$permissions->getPermissionValue('parts_stock', 'withdraw'),
$permissions->getPermissionValue('parts_stock', 'add')
);
$permissions->setPermissionValue('parts_stock', 'stocktake', $new_value);
}
}
}

View File

@@ -154,6 +154,7 @@ class UserAvatarHelper
$attachment_type = new AttachmentType();
$attachment_type->setName('Avatars');
$attachment_type->setFiletypeFilter('image/*');
$attachment_type->setAllowedTargets([UserAttachment::class]);
$this->entityManager->persist($attachment_type);
}

View File

@@ -48,6 +48,7 @@ enum PartTableColumns : string implements TranslatableInterface
case MPN = "manufacturer_product_number";
case CUSTOM_PART_STATE = 'partCustomState';
case MASS = "mass";
case GTIN = "gtin";
case TAGS = "tags";
case ATTACHMENTS = "attachments";
case EDIT = "edit";

View File

@@ -25,6 +25,7 @@ namespace App\Settings\SystemSettings;
use App\Form\Settings\LanguageMenuEntriesType;
use App\Form\Type\LocaleSelectType;
use App\Form\Type\TriStateCheckboxType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
@@ -46,7 +47,7 @@ class LocalizationSettings
#[Assert\Locale()]
#[Assert\NotBlank()]
#[SettingsParameter(label: new TM("settings.system.localization.locale"), formType: LocaleSelectType::class,
envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)]
envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)]
public string $locale = 'en';
#[Assert\Timezone()]
@@ -73,4 +74,14 @@ class LocalizationSettings
)]
#[Assert\All([new Assert\Locale()])]
public array $languageMenuEntries = [];
#[SettingsParameter(label: new TM("settings.system.localization.prices_include_tax_by_default"),
description: new TM("settings.system.localization.prices_include_tax_by_default.description"),
formType: TriStateCheckboxType::class
)]
/**
* Indicates whether prices should include tax by default. This is used when creating new pricedetails.
* Null means that the VAT state should be indetermine by default.
*/
public ?bool $pricesIncludeTaxByDefault = null;
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\LabelGenerationRequest;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LabelSystem\LabelProfile;
use App\Repository\DBElementRepository;
use App\Repository\LabelProfileRepository;
use App\Services\ElementTypeNameGenerator;
use App\Services\LabelSystem\LabelGenerator;
use App\Services\Misc\RangeParser;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class LabelGenerationProcessor implements ProcessorInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LabelGenerator $labelGenerator,
private readonly RangeParser $rangeParser,
private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
private readonly Security $security,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Response
{
// Check if user has permission to create labels
if (!$this->security->isGranted('@labels.create_labels')) {
throw new AccessDeniedHttpException('You do not have permission to generate labels.');
}
if (!$data instanceof LabelGenerationRequest) {
throw new BadRequestHttpException('Invalid request data for label generation.');
}
/** @var LabelGenerationRequest $request */
$request = $data;
// Fetch the label profile
/** @var LabelProfileRepository<LabelProfile> $profileRepo */
$profileRepo = $this->entityManager->getRepository(LabelProfile::class);
$profile = $profileRepo->find($request->profileId);
if (!$profile instanceof LabelProfile) {
throw new NotFoundHttpException(sprintf('Label profile with ID %d not found.', $request->profileId));
}
// Check if user has read permission for the profile
if (!$this->security->isGranted('read', $profile)) {
throw new AccessDeniedHttpException('You do not have permission to access this label profile.');
}
// Get label options from profile
$options = $profile->getOptions();
// Override element type if provided, otherwise use profile's default
if ($request->elementType !== null) {
$options->setSupportedElement($request->elementType);
}
// Parse element IDs from the range string
try {
$idArray = $this->rangeParser->parse($request->elementIds);
} catch (\InvalidArgumentException $e) {
throw new BadRequestHttpException('Invalid element IDs format: ' . $e->getMessage());
}
if (empty($idArray)) {
throw new BadRequestHttpException('No valid element IDs provided.');
}
// Fetch the target entities
/** @var DBElementRepository<AbstractDBElement> $repo */
$repo = $this->entityManager->getRepository($options->getSupportedElement()->getEntityClass());
$elements = $repo->getElementsFromIDArray($idArray);
if (empty($elements)) {
throw new NotFoundHttpException('No elements found with the provided IDs.');
}
// Generate the PDF
try {
$pdfContent = $this->labelGenerator->generateLabel($options, $elements);
} catch (\Exception $e) {
throw new BadRequestHttpException('Failed to generate label: ' . $e->getMessage());
}
// Generate filename
$filename = $this->generateFilename($elements[0], $profile);
// Return PDF as response
return new Response(
$pdfContent,
Response::HTTP_OK,
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
'Content-Length' => (string) strlen($pdfContent),
]
);
}
private function generateFilename(AbstractDBElement $element, LabelProfile $profile): string
{
$ret = 'label_' . $this->elementTypeNameGenerator->typeLabel($element);
$ret .= $element->getID();
$ret .= '_' . preg_replace('/[^a-z0-9_\-]/i', '_', $profile->getName());
return $ret . '.pdf';
}
}

View File

@@ -25,22 +25,34 @@ namespace App\Twig;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Misc\FAIconGenerator;
use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class AttachmentExtension extends AbstractExtension
final readonly class AttachmentExtension
{
public function __construct(protected AttachmentURLGenerator $attachmentURLGenerator, protected FAIconGenerator $FAIconGenerator)
public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator)
{
}
public function getFunctions(): array
/**
* Returns the URL of the thumbnail of the given attachment. Returns null if no thumbnail is available.
*/
#[AsTwigFunction("attachment_thumbnail")]
public function attachmentThumbnail(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
{
return [
/* Returns the URL to a thumbnail of the given attachment */
new TwigFunction('attachment_thumbnail', fn(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string => $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name)),
/* Returns the font awesome icon class which is representing the given file extension (We allow null here for attachments without extension) */
new TwigFunction('ext_to_fa_icon', fn(?string $extension): string => $this->FAIconGenerator->fileExtensionToFAType($extension ?? '')),
];
return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name);
}
/**
* Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available.
* Null is allowed for files withot extension
* @param string|null $extension
* @return string
*/
#[AsTwigFunction("ext_to_fa_icon")]
public function extToFAIcon(?string $extension): string
{
return $this->FAIconGenerator->fileExtensionToFAType($extension ?? '');
}
}

View File

@@ -23,19 +23,14 @@ declare(strict_types=1);
namespace App\Twig;
use Com\Tecnick\Barcode\Barcode;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\Attribute\AsTwigFunction;
final class BarcodeExtension extends AbstractExtension
final class BarcodeExtension
{
public function getFunctions(): array
{
return [
/* Generates a barcode with the given Type and Data and returns it as an SVG represenation */
new TwigFunction('barcode_svg', fn(string $content, string $type = 'QRCODE'): string => $this->barcodeSVG($content, $type)),
];
}
/**
* Generates a barcode in SVG format for the given content and type.
*/
#[AsTwigFunction('barcode_svg')]
public function barcodeSVG(string $content, string $type = 'QRCODE'): string
{
$barcodeFactory = new Barcode();

View File

@@ -41,6 +41,9 @@ use App\Exceptions\EntityNotSupportedException;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\TreeViewGenerator;
use Twig\Attribute\AsTwigFunction;
use Twig\Attribute\AsTwigTest;
use Twig\DeprecatedCallableInfo;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigTest;
@@ -48,61 +51,27 @@ use Twig\TwigTest;
/**
* @see \App\Tests\Twig\EntityExtensionTest
*/
final class EntityExtension extends AbstractExtension
final readonly class EntityExtension
{
public function __construct(protected EntityURLGenerator $entityURLGenerator, protected TreeViewGenerator $treeBuilder, private readonly ElementTypeNameGenerator $nameGenerator)
public function __construct(private EntityURLGenerator $entityURLGenerator, private TreeViewGenerator $treeBuilder, private ElementTypeNameGenerator $nameGenerator)
{
}
public function getTests(): array
/**
* Checks if the given variable is an entity (instance of AbstractDBElement).
*/
#[AsTwigTest("entity")]
public function entityTest(mixed $var): bool
{
return [
/* Checks if the given variable is an entitity (instance of AbstractDBElement) */
new TwigTest('entity', static fn($var) => $var instanceof AbstractDBElement),
];
return $var instanceof AbstractDBElement;
}
public function getFunctions(): array
{
return [
/* Returns a string representation of the given entity */
new TwigFunction('entity_type', fn(object $entity): ?string => $this->getEntityType($entity)),
/* Returns the URL to the given entity */
new TwigFunction('entity_url', fn(AbstractDBElement $entity, string $method = 'info'): string => $this->generateEntityURL($entity, $method)),
/* Returns the URL to the given entity in timetravel mode */
new TwigFunction('timetravel_url', fn(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string => $this->timeTravelURL($element, $dateTime)),
/* Generates a JSON array of the given tree */
new TwigFunction('tree_data', fn(AbstractDBElement $element, string $type = 'newEdit'): string => $this->treeData($element, $type)),
/* Gets a human readable label for the type of the given entity */
new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)),
new TwigFunction('type_label', fn(object|string $entity): string => $this->nameGenerator->typeLabel($entity)),
new TwigFunction('type_label_p', fn(object|string $entity): string => $this->nameGenerator->typeLabelPlural($entity)),
];
}
public function timeTravelURL(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string
{
try {
return $this->entityURLGenerator->timeTravelURL($element, $dateTime);
} catch (EntityNotSupportedException) {
return null;
}
}
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
{
$tree = $this->treeBuilder->getTreeView($element::class, null, $type, $element);
return json_encode($tree, JSON_THROW_ON_ERROR);
}
public function generateEntityURL(AbstractDBElement $entity, string $method = 'info'): string
{
return $this->entityURLGenerator->getURL($entity, $method);
}
public function getEntityType(object $entity): ?string
/**
* Returns a string representation of the given entity
*/
#[AsTwigFunction("entity_type")]
public function entityType(object $entity): ?string
{
$map = [
Part::class => 'part',
@@ -129,4 +98,69 @@ final class EntityExtension extends AbstractExtension
return null;
}
/**
* Returns the URL for the given entity and method. E.g. for a Part and method "edit", it will return the URL to edit this part.
*/
#[AsTwigFunction("entity_url")]
public function entityURL(AbstractDBElement $entity, string $method = 'info'): string
{
return $this->entityURLGenerator->getURL($entity, $method);
}
/**
* Returns the URL for the given entity in timetravel mode.
*/
#[AsTwigFunction("timetravel_url")]
public function timeTravelURL(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string
{
try {
return $this->entityURLGenerator->timeTravelURL($element, $dateTime);
} catch (EntityNotSupportedException) {
return null;
}
}
/**
* Generates a tree data structure for the given element, which can be used to display a tree view of the element and its related entities.
* The type parameter can be used to specify the type of tree view (e.g. "newEdit" for the tree view in the new/edit pages). The returned data is a JSON string.
*/
#[AsTwigFunction("tree_data")]
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
{
$tree = $this->treeBuilder->getTreeView($element::class, null, $type, $element);
return json_encode($tree, JSON_THROW_ON_ERROR);
}
/**
* Gets the localized type label for the given entity. E.g. for a Part, it will return "Part" in English and "Bauteil" in German.
* @deprecated Use the "type_label" function instead, which does the same but is more concise.
*/
#[AsTwigFunction("entity_type_label", deprecationInfo: new DeprecatedCallableInfo("Part-DB", "2", "Use the 'type_label' function instead."))]
public function entityTypeLabel(object|string $entity): string
{
return $this->nameGenerator->getLocalizedTypeLabel($entity);
}
/**
* Gets the localized type label for the given entity. E.g. for a Part, it will return "Part" in English and "Bauteil" in German.
*/
#[AsTwigFunction("type_label")]
public function typeLabel(object|string $entity): string
{
return $this->nameGenerator->typeLabel($entity);
}
/**
* Gets the localized plural type label for the given entity. E.g. for a Part, it will return "Parts" in English and "Bauteile" in German.
* @param object|string $entity
* @return string
*/
#[AsTwigFunction("type_label_p")]
public function typeLabelPlural(object|string $entity): string
{
return $this->nameGenerator->typeLabelPlural($entity);
}
}

View File

@@ -29,35 +29,28 @@ use App\Services\Formatters\MarkdownParser;
use App\Services\Formatters\MoneyFormatter;
use App\Services\Formatters\SIFormatter;
use Brick\Math\BigDecimal;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\Attribute\AsTwigFilter;
final class FormatExtension extends AbstractExtension
final readonly class FormatExtension
{
public function __construct(protected MarkdownParser $markdownParser, protected MoneyFormatter $moneyFormatter, protected SIFormatter $siformatter, protected AmountFormatter $amountFormatter)
public function __construct(private MarkdownParser $markdownParser, private MoneyFormatter $moneyFormatter, private SIFormatter $siformatter, private AmountFormatter $amountFormatter)
{
}
public function getFilters(): array
/**
* Mark the given text as markdown, which will be rendered in the browser
*/
#[AsTwigFilter("format_markdown", isSafe: ['html'], preEscape: 'html')]
public function formatMarkdown(string $markdown, bool $inline_mode = false): string
{
return [
/* Mark the given text as markdown, which will be rendered in the browser */
new TwigFilter('format_markdown', fn(string $markdown, bool $inline_mode = false): string => $this->markdownParser->markForRendering($markdown, $inline_mode), [
'pre_escape' => 'html',
'is_safe' => ['html'],
]),
/* Format the given amount as money, using a given currency */
new TwigFilter('format_money', fn($amount, ?Currency $currency = null, int $decimals = 5): string => $this->formatCurrency($amount, $currency, $decimals)),
/* Format the given number using SI prefixes and the given unit (string) */
new TwigFilter('format_si', fn($value, $unit, $decimals = 2, bool $show_all_digits = false): string => $this->siFormat($value, $unit, $decimals, $show_all_digits)),
/** Format the given amount using the given MeasurementUnit */
new TwigFilter('format_amount', fn($value, ?MeasurementUnit $unit, array $options = []): string => $this->amountFormat($value, $unit, $options)),
/** Format the given number of bytes as human-readable number */
new TwigFilter('format_bytes', fn(int $bytes, int $precision = 2): string => $this->formatBytes($bytes, $precision)),
];
return $this->markdownParser->markForRendering($markdown, $inline_mode);
}
public function formatCurrency($amount, ?Currency $currency = null, int $decimals = 5): string
/**
* Format the given amount as money, using a given currency
*/
#[AsTwigFilter("format_money")]
public function formatMoney(BigDecimal|float|string $amount, ?Currency $currency = null, int $decimals = 5): string
{
if ($amount instanceof BigDecimal) {
$amount = (string) $amount;
@@ -66,19 +59,22 @@ final class FormatExtension extends AbstractExtension
return $this->moneyFormatter->format($amount, $currency, $decimals);
}
public function siFormat($value, $unit, $decimals = 2, bool $show_all_digits = false): string
/**
* Format the given number using SI prefixes and the given unit (string)
*/
#[AsTwigFilter("format_si")]
public function siFormat(float $value, string $unit, int $decimals = 2, bool $show_all_digits = false): string
{
return $this->siformatter->format($value, $unit, $decimals);
}
public function amountFormat($value, ?MeasurementUnit $unit, array $options = []): string
#[AsTwigFilter("format_amount")]
public function amountFormat(float|int|string $value, ?MeasurementUnit $unit, array $options = []): string
{
return $this->amountFormatter->format($value, $unit, $options);
}
/**
* @param $bytes
*/
#[AsTwigFilter("format_bytes")]
public function formatBytes(int $bytes, int $precision = 2): string
{
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];

View File

@@ -23,31 +23,25 @@ declare(strict_types=1);
namespace App\Twig;
use Twig\Attribute\AsTwigFunction;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class InfoProviderExtension extends AbstractExtension
final readonly class InfoProviderExtension
{
public function __construct(
private readonly ProviderRegistry $providerRegistry
private ProviderRegistry $providerRegistry
) {}
public function getFunctions(): array
{
return [
new TwigFunction('info_provider', $this->getInfoProvider(...)),
new TwigFunction('info_provider_label', $this->getInfoProviderName(...))
];
}
/**
* Gets the info provider with the given key. Returns null, if the provider does not exist.
* @param string $key
* @return InfoProviderInterface|null
*/
private function getInfoProvider(string $key): ?InfoProviderInterface
#[AsTwigFunction(name: 'info_provider')]
public function getInfoProvider(string $key): ?InfoProviderInterface
{
try {
return $this->providerRegistry->getProviderByKey($key);
@@ -61,7 +55,8 @@ class InfoProviderExtension extends AbstractExtension
* @param string $key
* @return string|null
*/
private function getInfoProviderName(string $key): ?string
#[AsTwigFunction(name: 'info_provider_label')]
public function getInfoProviderName(string $key): ?string
{
try {
return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()['name'];
@@ -69,4 +64,4 @@ class InfoProviderExtension extends AbstractExtension
return null;
}
}
}
}

View File

@@ -25,21 +25,26 @@ namespace App\Twig;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Services\LogSystem\LogDataFormatter;
use App\Services\LogSystem\LogDiffFormatter;
use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class LogExtension extends AbstractExtension
final readonly class LogExtension
{
public function __construct(private readonly LogDataFormatter $logDataFormatter, private readonly LogDiffFormatter $logDiffFormatter)
public function __construct(private LogDataFormatter $logDataFormatter, private LogDiffFormatter $logDiffFormatter)
{
}
public function getFunctions(): array
#[AsTwigFunction(name: 'format_log_data', isSafe: ['html'])]
public function formatLogData(mixed $data, AbstractLogEntry $logEntry, string $fieldName): string
{
return [
new TwigFunction('format_log_data', fn($data, AbstractLogEntry $logEntry, string $fieldName): string => $this->logDataFormatter->formatData($data, $logEntry, $fieldName), ['is_safe' => ['html']]),
new TwigFunction('format_log_diff', fn($old_data, $new_data): string => $this->logDiffFormatter->formatDiff($old_data, $new_data), ['is_safe' => ['html']]),
];
return $this->logDataFormatter->formatData($data, $logEntry, $fieldName);
}
#[AsTwigFunction(name: 'format_log_diff', isSafe: ['html'])]
public function formatLogDiff(mixed $old_data, mixed $new_data): string
{
return $this->logDiffFormatter->formatDiff($old_data, $new_data);
}
}

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Twig;
use Twig\Attribute\AsTwigFunction;
use App\Settings\SettingsIcon;
use Symfony\Component\HttpFoundation\Request;
use App\Services\LogSystem\EventCommentType;
@@ -31,23 +32,14 @@ use Twig\TwigFunction;
use App\Services\LogSystem\EventCommentNeededHelper;
use Twig\Extension\AbstractExtension;
final class MiscExtension extends AbstractExtension
final readonly class MiscExtension
{
public function __construct(private readonly EventCommentNeededHelper $eventCommentNeededHelper)
public function __construct(private EventCommentNeededHelper $eventCommentNeededHelper)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('event_comment_needed', $this->evenCommentNeeded(...)),
new TwigFunction('settings_icon', $this->settingsIcon(...)),
new TwigFunction('uri_without_host', $this->uri_without_host(...))
];
}
private function evenCommentNeeded(string|EventCommentType $operation_type): bool
#[AsTwigFunction(name: 'event_comment_needed')]
public function evenCommentNeeded(string|EventCommentType $operation_type): bool
{
if (is_string($operation_type)) {
$operation_type = EventCommentType::from($operation_type);
@@ -63,7 +55,8 @@ final class MiscExtension extends AbstractExtension
* @return string|null
* @throws \ReflectionException
*/
private function settingsIcon(string|object $objectOrClass): ?string
#[AsTwigFunction(name: 'settings_icon')]
public function settingsIcon(string|object $objectOrClass): ?string
{
//If the given object is a proxy, then get the real object
if (is_a($objectOrClass, SettingsProxyInterface::class)) {
@@ -82,6 +75,7 @@ final class MiscExtension extends AbstractExtension
* @param Request $request
* @return string
*/
#[AsTwigFunction(name: 'uri_without_host')]
public function uri_without_host(Request $request): string
{
if (null !== $qs = $request->getQueryString()) {

View File

@@ -23,14 +23,18 @@ declare(strict_types=1);
namespace App\Twig\Sandbox;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Parts\Part;
use App\Repository\AbstractPartsContainingRepository;
use App\Services\LabelSystem\LabelTextReplacer;
use Doctrine\ORM\EntityManagerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class SandboxedLabelExtension extends AbstractExtension
{
public function __construct(private readonly LabelTextReplacer $labelTextReplacer)
public function __construct(private readonly LabelTextReplacer $labelTextReplacer, private readonly EntityManagerInterface $em)
{
}
@@ -39,6 +43,11 @@ class SandboxedLabelExtension extends AbstractExtension
{
return [
new TwigFunction('placeholder', fn(string $text, object $label_target) => $this->labelTextReplacer->handlePlaceholderOrReturnNull($text, $label_target)),
new TwigFunction("associated_parts", $this->associatedParts(...)),
new TwigFunction("associated_parts_count", $this->associatedPartsCount(...)),
new TwigFunction("associated_parts_r", $this->associatedPartsRecursive(...)),
new TwigFunction("associated_parts_count_r", $this->associatedPartsCountRecursive(...)),
];
}
@@ -48,4 +57,37 @@ class SandboxedLabelExtension extends AbstractExtension
new TwigFilter('placeholders', fn(string $text, object $label_target) => $this->labelTextReplacer->replace($text, $label_target)),
];
}
}
/**
* Returns all parts associated with the given element.
* @param AbstractPartsContainingDBElement $element
* @return Part[]
*/
public function associatedParts(AbstractPartsContainingDBElement $element): array
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getParts($element);
}
public function associatedPartsCount(AbstractPartsContainingDBElement $element): int
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsCount($element);
}
public function associatedPartsRecursive(AbstractPartsContainingDBElement $element): array
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsRecursive($element);
}
public function associatedPartsCountRecursive(AbstractPartsContainingDBElement $element): int
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsCountRecursive($element);
}
}

View File

@@ -22,7 +22,11 @@ declare(strict_types=1);
*/
namespace App\Twig;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Twig\Attribute\AsTwigTest;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -32,58 +36,54 @@ use Twig\TwigTest;
* The functionalities here extend the Twig with some core functions, which are independently of Part-DB.
* @see \App\Tests\Twig\TwigCoreExtensionTest
*/
final class TwigCoreExtension extends AbstractExtension
final readonly class TwigCoreExtension
{
private readonly ObjectNormalizer $objectNormalizer;
private NormalizerInterface $objectNormalizer;
public function __construct()
{
$this->objectNormalizer = new ObjectNormalizer();
}
public function getFunctions(): array
/**
* Checks if the given variable is an instance of the given class/interface/enum. E.g. `x is instanceof('App\Entity\Parts\Part')`
* @param mixed $var
* @param string $instance
* @return bool
*/
#[AsTwigTest("instanceof")]
public function testInstanceOf(mixed $var, string $instance): bool
{
return [
/* Returns the enum cases as values */
new TwigFunction('enum_cases', $this->getEnumCases(...)),
];
}
if (!class_exists($instance) && !interface_exists($instance) && !enum_exists($instance)) {
throw new \InvalidArgumentException(sprintf('The given class/interface/enum "%s" does not exist!', $instance));
}
public function getTests(): array
{
return [
/*
* Checks if a given variable is an instance of a given class. E.g. ` x is instanceof('App\Entity\Parts\Part')`
*/
new TwigTest('instanceof', static fn($var, $instance) => $var instanceof $instance),
/* Checks if a given variable is an object. E.g. `x is object` */
new TwigTest('object', static fn($var): bool => is_object($var)),
new TwigTest('enum', fn($var) => $var instanceof \UnitEnum),
];
return $var instanceof $instance;
}
/**
* @param string $enum_class
* @phpstan-param class-string $enum_class
* Checks if the given variable is an object. This can be used to check if a variable is an object, without knowing the exact class of the object. E.g. `x is object`
* @param mixed $var
* @return bool
*/
public function getEnumCases(string $enum_class): array
#[AsTwigTest("object")]
public function testObject(mixed $var): bool
{
if (!enum_exists($enum_class)) {
throw new \InvalidArgumentException(sprintf('The given class "%s" is not an enum!', $enum_class));
}
/** @noinspection PhpUndefinedMethodInspection */
return ($enum_class)::cases();
return is_object($var);
}
public function getFilters(): array
/**
* Checks if the given variable is an enum (instance of UnitEnum). This can be used to check if a variable is an enum, without knowing the exact class of the enum. E.g. `x is enum`
* @param mixed $var
* @return bool
*/
#[AsTwigTest("enum")]
public function testEnum(mixed $var): bool
{
return [
/* Converts the given object to an array representation of the public/accessible properties */
new TwigFilter('to_array', fn($object) => $this->toArray($object)),
];
return $var instanceof \UnitEnum;
}
#[AsTwigFilter('to_array')]
public function toArray(object|array $object): array
{
//If it is already an array, we can just return it

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Twig;
use Twig\Attribute\AsTwigFunction;
use App\Services\System\UpdateAvailableFacade;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Extension\AbstractExtension;
@@ -31,26 +32,18 @@ use Twig\TwigFunction;
/**
* Twig extension for update-related functions.
*/
final class UpdateExtension extends AbstractExtension
final readonly class UpdateExtension
{
public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager,
private readonly Security $security)
public function __construct(private UpdateAvailableFacade $updateAvailableManager,
private Security $security)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('is_update_available', $this->isUpdateAvailable(...)),
new TwigFunction('get_latest_version', $this->getLatestVersion(...)),
new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)),
];
}
/**
* Check if an update is available and the user has permission to see it.
*/
#[AsTwigFunction(name: 'is_update_available')]
public function isUpdateAvailable(): bool
{
// Only show to users with the show_updates permission
@@ -64,6 +57,7 @@ final class UpdateExtension extends AbstractExtension
/**
* Get the latest available version string.
*/
#[AsTwigFunction(name: 'get_latest_version')]
public function getLatestVersion(): string
{
return $this->updateAvailableManager->getLatestVersionString();
@@ -72,6 +66,7 @@ final class UpdateExtension extends AbstractExtension
/**
* Get the URL to the latest version release page.
*/
#[AsTwigFunction(name: 'get_latest_version_url')]
public function getLatestVersionUrl(): string
{
return $this->updateAvailableManager->getLatestVersionUrl();

View File

@@ -41,51 +41,24 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Base\AbstractDBElement;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
use App\Entity\UserSystem\User;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Repository\LogEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* @see \App\Tests\Twig\UserExtensionTest
*/
final class UserExtension extends AbstractExtension
final readonly class UserExtension
{
private readonly LogEntryRepository $repo;
public function __construct(EntityManagerInterface $em,
private readonly Security $security,
private readonly UrlGeneratorInterface $urlGenerator)
public function __construct(
private Security $security,
private UrlGeneratorInterface $urlGenerator)
{
$this->repo = $em->getRepository(AbstractLogEntry::class);
}
public function getFilters(): array
{
return [
new TwigFilter('remove_locale_from_path', fn(string $path): string => $this->removeLocaleFromPath($path)),
];
}
public function getFunctions(): array
{
return [
/* Returns the user which has edited the given entity the last time. */
new TwigFunction('last_editing_user', fn(AbstractDBElement $element): ?User => $this->repo->getLastEditingUser($element)),
/* Returns the user which has created the given entity. */
new TwigFunction('creating_user', fn(AbstractDBElement $element): ?User => $this->repo->getCreatingUser($element)),
new TwigFunction('impersonator_user', $this->getImpersonatorUser(...)),
new TwigFunction('impersonation_active', $this->isImpersonationActive(...)),
new TwigFunction('impersonation_path', $this->getImpersonationPath(...)),
];
}
/**
@@ -93,6 +66,7 @@ final class UserExtension extends AbstractExtension
* If the current user is not impersonated, null is returned.
* @return User|null
*/
#[AsTwigFunction(name: 'impersonator_user')]
public function getImpersonatorUser(): ?User
{
$token = $this->security->getToken();
@@ -107,11 +81,13 @@ final class UserExtension extends AbstractExtension
return null;
}
#[AsTwigFunction(name: 'impersonation_active')]
public function isImpersonationActive(): bool
{
return $this->security->isGranted('IS_IMPERSONATOR');
}
#[AsTwigFunction(name: 'impersonation_path')]
public function getImpersonationPath(User $user, string $route_name = 'homepage'): string
{
if (! $this->security->isGranted('CAN_SWITCH_USER', $user)) {
@@ -124,6 +100,7 @@ final class UserExtension extends AbstractExtension
/**
* This function/filter generates a path.
*/
#[AsTwigFilter(name: 'remove_locale_from_path')]
public function removeLocaleFromPath(string $path): string
{
//Ensure the path has the correct format

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Twig;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\UserSystem\User;
use App\Repository\LogEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Twig\Attribute\AsTwigFunction;
final readonly class UserRepoExtension
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
/**
* Returns the user which has edited the given entity the last time.
*/
#[AsTwigFunction('creating_user')]
public function creatingUser(AbstractDBElement $element): ?User
{
return $this->entityManager->getRepository(AbstractLogEntry::class)->getCreatingUser($element);
}
/**
* Returns the user which has edited the given entity the last time.
*/
#[AsTwigFunction('last_editing_user')]
public function lastEditingUser(AbstractDBElement $element): ?User
{
return $this->entityManager->getRepository(AbstractLogEntry::class)->getLastEditingUser($element);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Attribute;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Entity\Parts\Part;

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* A constraint to ensure that a GTIN is valid.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValidGTIN extends Constraint
{
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Validator\Constraints;
use GtinValidation\GtinValidator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class ValidGTINValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof ValidGTIN) {
throw new UnexpectedTypeException($constraint, ValidGTIN::class);
}
if (null === $value || '' === $value) {
return;
}
if (!is_string($value)) {
throw new UnexpectedTypeException($value, 'string');
}
$gtinValidator = new GtinValidator($value);
if (!$gtinValidator->isValid()) {
$this->context->buildViolation('validator.invalid_gtin')
->addViolation();
}
}
}

View File

@@ -6,6 +6,7 @@
{% block additional_controls %}
{{ form_row(form.filetype_filter) }}
{{ form_row(form.allowed_targets) }}
{{ form_row(form.alternative_names) }}
{% endblock %}

View File

@@ -100,6 +100,17 @@
{%- endif -%}
{%- endblock tristate_widget %}
{% block tristate_row -%}
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('mb-3') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
<div class="{{ block('form_label_class') }}"></div>{#--#}
<div class="{{ block('form_group_class') }}">
{{- form_widget(form) -}}
{{- form_help(form) -}}
{{- form_errors(form) -}}
</div>{#--#}
</div>
{%- endblock tristate_row %}
{%- block choice_widget_collapsed -%}
{# Only add the BS5 form-select class if we dont use bootstrap-selectpicker #}
{# {% if attr["data-controller"] is defined and attr["data-controller"] not in ["elements--selectpicker"] %}
@@ -144,3 +155,8 @@
{{- parent() -}}
{% endif %}
{% endblock %}
{% block boolean_constraint_widget %}
{{ form_widget(form.value) }}
{{ form_errors(form.value) }}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More