mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-21 09:12:29 +01:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cde4c3a8 | ||
|
|
28e6ca52fe | ||
|
|
5b4c1505b7 | ||
|
|
8ad3c2e612 | ||
|
|
d7ed2225b4 | ||
|
|
7d6b84af3d | ||
|
|
80492a7b68 | ||
|
|
7069af4054 | ||
|
|
05a9e4d035 | ||
|
|
be808e28bc | ||
|
|
7354b37ef6 | ||
|
|
6afca44897 | ||
|
|
c17cf2baa1 | ||
|
|
c00556829a | ||
|
|
f024c4b09e | ||
|
|
8e0fcdb73b | ||
|
|
e19929249f | ||
|
|
f6764170e1 | ||
|
|
1641708508 | ||
|
|
97a74815d3 | ||
|
|
7998cdcd71 | ||
|
|
5e9f7a11a3 | ||
|
|
1c6bf3f472 | ||
|
|
aed2652f1d | ||
|
|
233c5e8550 | ||
|
|
6b83c772cc | ||
|
|
1996db6a53 | ||
|
|
f69b0889eb | ||
|
|
c8b1320bb9 | ||
|
|
e11cb7d5cb | ||
|
|
097041a43a | ||
|
|
b21d294cf8 | ||
|
|
43d72faf48 | ||
|
|
bc9a93d71f | ||
|
|
df0ac76394 | ||
|
|
66040b687f | ||
|
|
7a83581597 | ||
|
|
47c0d78985 | ||
|
|
76f0b05a09 | ||
|
|
35598df354 | ||
|
|
3c87fe0932 | ||
|
|
d8fdaa9529 | ||
|
|
2f9601364e | ||
|
|
e5231e29f2 | ||
|
|
8ac8743792 | ||
|
|
586375d921 | ||
|
|
41252d8bb9 | ||
|
|
4740b6d19e | ||
|
|
5a47b15c97 | ||
|
|
3bff5fa8bd | ||
|
|
f95e39748e | ||
|
|
90c82aab2e | ||
|
|
a4c2b8f885 | ||
|
|
2c56ec746c | ||
|
|
35e844dd7b | ||
|
|
4de6dbba27 | ||
|
|
a962e5e019 | ||
|
|
1130f71075 | ||
|
|
fd76ca12fc | ||
|
|
57c8368b5e | ||
|
|
7fd7697c02 |
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
worker {
|
||||
file ./public/index.php
|
||||
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
|
||||
}
|
||||
|
||||
113
.github/workflows/docker_build.yml
vendored
113
.github/workflows/docker_build.yml
vendored
@@ -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 }}
|
||||
|
||||
113
.github/workflows/docker_frankenphp.yml
vendored
113
.github/workflows/docker_frankenphp.yml
vendored
@@ -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 }}
|
||||
|
||||
115
Dockerfile
115
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,11 +108,19 @@ export default class extends Controller {
|
||||
const raw_order = saved_state.order;
|
||||
|
||||
settings.initial_order = raw_order.map((order) => {
|
||||
//Skip if direction is empty, as this is the default, otherwise datatables server is confused when the order is sent in the request, but the initial order is set to an empty direction
|
||||
if (order[1] === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
column: order[0],
|
||||
dir: order[1]
|
||||
}
|
||||
});
|
||||
|
||||
//Remove null values from the initial_order array
|
||||
settings.initial_order = settings.initial_order.filter(order => order !== null);
|
||||
}
|
||||
|
||||
let options = {
|
||||
|
||||
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
633
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
129
migrations/Version20260208131116.php
Normal file
129
migrations/Version20260208131116.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
;
|
||||
|
||||
84
src/ApiResource/LabelGenerationRequest.php
Normal file
84
src/ApiResource/LabelGenerationRequest.php
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber\UserSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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']),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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,13 @@ 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,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
56
src/Form/Type/AttachmentTypeType.php
Normal file
56
src/Form/Type/AttachmentTypeType.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
|
||||
'accept' => 'image/*',
|
||||
],
|
||||
'constraints' => [
|
||||
new File([
|
||||
'maxSize' => '5M',
|
||||
]),
|
||||
new File(maxSize: '5M'),
|
||||
],
|
||||
])
|
||||
->add('aboutMe', RichTextEditorType::class, [
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ final class LabelHTMLGenerator
|
||||
'paper_height' => $options->getHeight(),
|
||||
]
|
||||
);
|
||||
} catch (Error $exception) {
|
||||
} catch (\Throwable $exception) {
|
||||
throw new TwigModeException($exception);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
141
src/State/LabelGenerationProcessor.php
Normal file
141
src/State/LabelGenerationProcessor.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
57
src/Twig/UserRepoExtension.php
Normal file
57
src/Twig/UserRepoExtension.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Attribute;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
|
||||
35
src/Validator/Constraints/ValidGTIN.php
Normal file
35
src/Validator/Constraints/ValidGTIN.php
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
54
src/Validator/Constraints/ValidGTINValidator.php
Normal file
54
src/Validator/Constraints/ValidGTINValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
{% block additional_controls %}
|
||||
{{ form_row(form.filetype_filter) }}
|
||||
{{ form_row(form.allowed_targets) }}
|
||||
{{ form_row(form.alternative_names) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user