diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 05856a31..e775af8a 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -107,7 +107,14 @@ export default class extends Controller { } if (data.short) { - return '
' + escape(data.short) + '
'; + let short = escape(data.short) + + //Make text italic, if the item is not yet in the DB + if (data.not_in_db_yet) { + short = '' + short + ''; + } + + return '
' + short + '
'; } let name = ""; diff --git a/src/Entity/PriceInformations/Currency.php b/src/Entity/PriceInformations/Currency.php index 80fe6c5e..548e45f6 100644 --- a/src/Entity/PriceInformations/Currency.php +++ b/src/Entity/PriceInformations/Currency.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Entity\PriceInformations; +use App\Repository\CurrencyRepository; use Doctrine\DBAL\Types\Types; use App\Entity\Attachments\CurrencyAttachment; use App\Entity\Base\AbstractStructuralDBElement; @@ -42,7 +43,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @extends AbstractStructuralDBElement */ #[UniqueEntity('iso_code')] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: CurrencyRepository::class)] #[ORM\Table(name: 'currencies')] #[ORM\Index(name: 'currency_idx_name', columns: ['name'])] #[ORM\Index(name: 'currency_idx_parent_name', columns: ['parent_id', 'name'])] diff --git a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php index 79dae8a2..402270ce 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php @@ -102,6 +102,9 @@ class StructuralEntityChoiceHelper $symbol = empty($choice->getIsoCode()) ? null : Currencies::getSymbol($choice->getIsoCode()); $tmp['data-short'] = $options['short'] ? $symbol : $choice->getName(); + //Show entities that are not added to DB yet separately from other entities + $tmp['data-not_in_db_yet'] = $choice->getID() === null; + return $tmp + [ 'data-symbol' => $symbol, ]; diff --git a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php index 97230319..5d466a73 100644 --- a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php +++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Form\Type\Helper; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\PriceInformations\Currency; use App\Repository\StructuralDBElementRepository; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; @@ -59,6 +60,12 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader public function createNewEntitiesFromValue(string $value): array { if (!$this->options['allow_add']) { + //Always allow the starting element to be added + if ($this->starting_element !== null && $this->starting_element->getID() === null) { + $this->entityManager->persist($this->starting_element); + return [$this->starting_element]; + } + throw new \RuntimeException('Cannot create new entity, because allow_add is not enabled!'); } diff --git a/src/Repository/CurrencyRepository.php b/src/Repository/CurrencyRepository.php new file mode 100644 index 00000000..47642f4b --- /dev/null +++ b/src/Repository/CurrencyRepository.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Repository; + +use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\PriceInformations\Currency; +use Symfony\Component\Intl\Currencies; + +/** + * @extends StructuralDBElementRepository + */ +class CurrencyRepository extends StructuralDBElementRepository +{ + /** + * Finds or create a currency with the given ISO code. + * @param string $iso_code + * @return Currency + */ + public function findOrCreateByISOCode(string $iso_code): Currency + { + //Normalize ISO code + $iso_code = strtoupper($iso_code); + + //Try to find currency + $currency = $this->findOneBy(['iso_code' => $iso_code]); + if ($currency !== null) { + return $currency; + } + + //Create currency if it does not exist + $name = Currencies::getName($iso_code); + + $currency = $this->findOrCreateForInfoProvider($name); + $currency->setIsoCode($iso_code); + + return $currency; + } +} \ No newline at end of file diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php index 48c10414..529cce79 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -192,6 +192,7 @@ class StructuralDBElementRepository extends NamedDBElementRepository * Also, it will try to find the element using the additional names field, of the elements. * @param string $name * @return AbstractStructuralDBElement|null + * @phpstan-return TEntityClass|null */ public function findForInfoProvider(string $name): ?AbstractStructuralDBElement { @@ -228,6 +229,7 @@ class StructuralDBElementRepository extends NamedDBElementRepository * Similar to findForInfoProvider, but will create a new element with the given name if none was found. * @param string $name * @return AbstractStructuralDBElement + * @phpstan-return TEntityClass */ public function findOrCreateForInfoProvider(string $name): AbstractStructuralDBElement { diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 13bb93d8..8854241d 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -44,6 +44,8 @@ class PartDetailDTO extends SearchResultDTO public readonly ?array $datasheets = null, /** @var ParameterDTO[]|null */ public readonly ?array $parameters = null, + /** @var PurchaseInfoDTO[]|null */ + public readonly ?array $vendor_infos = null, ) { parent::__construct( provider_key: $provider_key, diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php new file mode 100644 index 00000000..e10166ef --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use Brick\Math\BigDecimal; + +/** + * This DTO represents a price for a single unit in a certain discount range + */ +class PriceDTO +{ + private readonly BigDecimal $price_as_big_decimal; + + public function __construct( + /** @var float The minimum amount that needs to get ordered for this price to be valid */ + public readonly float $minimum_discount_amount, + /** @var string The price as string (with .) */ + public readonly string $price, + /** @var string The currency of the used ISO code of this price detail */ + public readonly ?string $currency_iso_code, + /** @var bool If the price includes tax */ + public readonly ?bool $includes_tax = true, + ) + { + $this->price_as_big_decimal = BigDecimal::of($this->price); + } + + public function getPriceAsBigDecimal(): BigDecimal + { + return $this->price_as_big_decimal; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php new file mode 100644 index 00000000..09bbfeb2 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -0,0 +1,44 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +class PurchaseInfoDTO +{ + public function __construct( + public readonly string $distributor_name, + public readonly string $order_number, + /** @var PriceDTO[] */ + public readonly array $prices, + /** @var string|null An url to the product page of the vendor */ + public readonly ?string $product_url = null, + ) + { + //Ensure that the prices are PriceDTO instances + foreach ($this->prices as $price) { + if (!$price instanceof PriceDTO) { + throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances'); + } + } + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index b11382d2..96323df4 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -31,9 +31,16 @@ use App\Entity\Parameters\PartParameter; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Currency; +use App\Entity\PriceInformations\Orderdetail; +use App\Entity\PriceInformations\Pricedetail; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use Brick\Math\BigDecimal; use Doctrine\ORM\EntityManagerInterface; /** @@ -42,7 +49,7 @@ use Doctrine\ORM\EntityManagerInterface; class DTOtoEntityConverter { - public function __construct(private readonly EntityManagerInterface $em) + public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency) { } @@ -60,6 +67,35 @@ class DTOtoEntityConverter return $entity; } + public function convertPrice(PriceDTO $dto, Pricedetail $entity = new Pricedetail()): Pricedetail + { + $entity->setMinDiscountQuantity($dto->minimum_discount_amount); + $entity->setPrice($dto->getPriceAsBigDecimal()); + + //Currency TODO + if ($dto->currency_iso_code !== null) { + $entity->setCurrency($this->getCurrency($dto->currency_iso_code)); + } else { + $entity->setCurrency(null); + } + + + return $entity; + } + + public function convertPurchaseInfo(PurchaseInfoDTO $dto, Orderdetail $entity = new Orderdetail()): Orderdetail + { + $entity->setSupplierpartnr($dto->order_number); + $entity->setSupplierProductUrl($dto->product_url ?? ''); + + $entity->setSupplier($this->getOrCreateEntityNonNull(Supplier::class, $dto->distributor_name)); + foreach ($dto->prices as $price) { + $entity->addPricedetail($this->convertPrice($price)); + } + + return $entity; + } + public function convertFile(FileDTO $dto, PartAttachment $entity = new PartAttachment()): PartAttachment { $entity->setURL($dto->url); @@ -101,9 +137,22 @@ class DTOtoEntityConverter $entity->addAttachment($this->convertFile($datasheet)); } + //Add orderdetails and prices + foreach ($dto->vendor_infos ?? [] as $vendor_info) { + $entity->addOrderdetail($this->convertPurchaseInfo($vendor_info)); + } + return $entity; } + /** + * @template T of AbstractStructuralDBElement + * @param string $class + * @phpstan-param class-string $class + * @param string|null $name + * @return AbstractStructuralDBElement|null + * @phpstan-return T|null + */ private function getOrCreateEntity(string $class, ?string $name): ?AbstractStructuralDBElement { //Fall through to make converting easier @@ -111,7 +160,30 @@ class DTOtoEntityConverter return null; } + return $this->getOrCreateEntityNonNull($class, $name); + } + + /** + * @template T of AbstractStructuralDBElement + * @param string $class The class of the entity to create + * @phpstan-param class-string $class + * @param string $name The name of the entity to create + * @return AbstractStructuralDBElement + * @phpstan-return T|null + */ + private function getOrCreateEntityNonNull(string $class, string $name): AbstractStructuralDBElement + { return $this->em->getRepository($class)->findOrCreateForInfoProvider($name); } + private function getCurrency(string $iso_code): ?Currency + { + //Check if the currency is the base currency (then we can just return null) + if ($iso_code === $this->base_currency) { + return null; + } + + return $this->em->getRepository(Currency::class)->findOrCreateByISOCode($iso_code); + } + } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 854b4a1b..b1954c51 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -28,7 +28,9 @@ use App\Form\InfoProviderSystem\ProviderSelectType; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use Symfony\Contracts\HttpClient\HttpClientInterface; class Element14Provider implements InfoProviderInterface @@ -39,6 +41,8 @@ class Element14Provider implements InfoProviderInterface private const API_VERSION_NUMBER = '1.2'; private const NUMBER_OF_RESULTS = 20; + public const DISTRIBUTOR_NAME = 'Farnell'; + private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant', 'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode']; @@ -105,15 +109,21 @@ class Element14Provider implements InfoProviderInterface mpn: $product['translatedManufacturerPartNumber'], preview_image_url: $this->toImageUrl($product['image'] ?? null), manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), - provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'], + provider_url: $this->generateProductURL($product['sku']), datasheets: $this->parseDataSheets($product['datasheets'] ?? null), parameters: $this->attributesToParameters($product['attributes'] ?? null), + vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? []) ); } return $result; } + private function generateProductURL($sku): string + { + return 'https://' . self::FARNELL_STORE_ID . '/' . $sku; + } + /** * @param mixed[]|null $datasheets * @return FileDTO[]|null Array of FileDTOs @@ -147,6 +157,90 @@ class Element14Provider implements InfoProviderInterface return 'https://' . self::FARNELL_STORE_ID . '/productimages/standard/' . $locale . $image['baseName']; } + /** + * Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO + * @param string $sku + * @param array $prices + * @return array + */ + private function pricesToVendorInfo(string $sku, array $prices): array + { + $price_dtos = []; + + foreach ($prices as $price) { + $price_dtos[] = new PriceDTO( + minimum_discount_amount: $price['from'], + price: (string) $price['cost'], + currency_iso_code: $this->getUsedCurrency(), + includes_tax: false, + ); + } + + return [ + new PurchaseInfoDTO( + distributor_name: self::DISTRIBUTOR_NAME, + order_number: $sku, + prices: $price_dtos, + product_url: $this->generateProductURL($sku) + ) + ]; + } + + public function getUsedCurrency(): string + { + //Decide based on the shop ID + return match (self::FARNELL_STORE_ID) { + 'bg.farnell.com' => 'EUR', + 'cz.farnell.com' => 'CZK', + 'dk.farnell.com' => 'DKK', + 'at.farnell.com' => 'EUR', + 'ch.farnell.com' => 'CHF', + 'de.farnell.com' => 'EUR', + 'cpc.farnell.com' => 'GBP', + 'cpcireland.farnell.com' => 'EUR', + 'export.farnell.com' => 'GBP', + 'onecall.farnell.com' => 'GBP', + 'ie.farnell.com' => 'EUR', + 'il.farnell.com' => 'USD', + 'uk.farnell.com' => 'GBP', + 'es.farnell.com' => 'EUR', + 'ee.farnell.com' => 'EUR', + 'fi.farnell.com' => 'EUR', + 'fr.farnell.com' => 'EUR', + 'hu.farnell.com' => 'HUF', + 'it.farnell.com' => 'EUR', + 'lt.farnell.com' => 'EUR', + 'lv.farnell.com' => 'EUR', + 'be.farnell.com' => 'EUR', + 'nl.farnell.com' => 'EUR', + 'no.farnell.com' => 'NOK', + 'pl.farnell.com' => 'PLN', + 'pt.farnell.com' => 'EUR', + 'ro.farnell.com' => 'EUR', + 'ru.farnell.com' => 'RUB', + 'sk.farnell.com' => 'EUR', + 'si.farnell.com' => 'EUR', + 'se.farnell.com' => 'SEK', + 'tr.farnell.com' => 'TRY', + 'canada.newark.com' => 'CAD', + 'mexico.newark.com' => 'MXN', + 'www.newark.com' => 'USD', + 'cn.element14.com' => 'CNY', + 'au.element14.com' => 'AUD', + 'nz.element14.com' => 'NZD', + 'hk.element14.com' => 'HKD', + 'sg.element14.com' => 'SGD', + 'my.element14.com' => 'MYR', + 'ph.element14.com' => 'PHP', + 'th.element14.com' => 'THB', + 'in.element14.com' => 'INR', + 'tw.element14.com' => 'TWD', + 'kr.element14.com' => 'KRW', + 'vn.element14.com' => 'VND', + default => throw new \RuntimeException('Unknown store ID: ' . self::FARNELL_STORE_ID) + }; + } + /** * @param array|null $attributes * @return ParameterDTO[]|null