Merge branch 'gtin'

This commit is contained in:
Jan Böhmer 2026-02-14 22:12:39 +01:00
commit 7a83581597
71 changed files with 1405 additions and 92 deletions

View file

@ -54,12 +54,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;
@ -463,6 +465,54 @@ final class PartController extends AbstractController
);
}
#[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])]
#[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')]
public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper,
Request $request,
): Response
{
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
//Check that the user is allowed to stocktake the partlot
$this->denyAccessUnlessGranted('stocktake', $partLot);
if (!$partLot instanceof PartLot) {
throw new \RuntimeException('Part lot not found!');
}
//Ensure that the partlot belongs to the part
if ($partLot->getPart() !== $part) {
throw new \RuntimeException("The origin partlot does not belong to the part!");
}
$actualAmount = (float) $request->request->get('actual_amount');
$comment = $request->request->get('comment');
$timestamp = null;
$timestamp_str = $request->request->getString('timestamp', '');
//Try to parse the timestamp
if ($timestamp_str !== '') {
$timestamp = new DateTime($timestamp_str);
}
$withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp);
//Ensure that the timestamp is not in the future
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
throw new \LogicException("The timestamp must not be in the future!");
}
//Save the changes to the DB
$em->flush();
$this->addFlash('success', 'part.withdraw.success');
//If a redirect was passed, then redirect there
if ($request->request->get('_redirect')) {
return $this->redirect($request->request->get('_redirect'));
}
//Otherwise just redirect to the part page
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
{

View file

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

View file

@ -218,6 +218,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'),
])

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),
]);

View file

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

View file

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

View file

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

View file

@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Services\LogSystem\EventCommentType;
use App\Settings\MiscSettings\IpnSuggestSettings;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -63,6 +64,7 @@ class PartBaseType extends AbstractType
protected UrlGeneratorInterface $urlGenerator,
protected EventCommentNeededHelper $event_comment_needed_helper,
protected IpnSuggestSettings $ipnSuggestSettings,
private readonly LocalizationSettings $localizationSettings,
) {
}
@ -216,7 +218,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 +269,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 +280,7 @@ class PartBaseType extends AbstractType
'allow_delete' => true,
'label' => false,
'by_reference' => false,
'prototype_data' => new Orderdetail(),
'prototype_data' => $orderdetailPrototype,
'entry_options' => [
'measurement_unit' => $part->getPartUnit(),
],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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