Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions migrations/Version20260907090000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;

final class Version20260907090000 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add available_amount and available_amount_updated_at columns to the orderdetails table, to store the stock a supplier had for a part and when that was retrieved';
}

public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails ADD available_amount DOUBLE PRECISION DEFAULT NULL, ADD available_amount_updated_at DATETIME DEFAULT NULL');
}

public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails DROP COLUMN available_amount, DROP COLUMN available_amount_updated_at');
}

public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails ADD COLUMN available_amount DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE orderdetails ADD COLUMN available_amount_updated_at DATETIME DEFAULT NULL');
}

public function sqLiteDown(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails DROP COLUMN available_amount');
$this->addSql('ALTER TABLE orderdetails DROP COLUMN available_amount_updated_at');
}

public function postgreSQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails ADD available_amount DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE orderdetails ADD available_amount_updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}

public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails DROP COLUMN available_amount');
$this->addSql('ALTER TABLE orderdetails DROP COLUMN available_amount_updated_at');
}
}
41 changes: 41 additions & 0 deletions src/DataTables/Helpers/PartDataTableHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,45 @@ public function renderAmount(Part $context): string

return $ret;
}

/**
* Renders the best stock any supplier of this part has, as retrieved from the info providers.
* Obsolete orderdetails and orderdetails without a known stock are ignored; if no supplier stock is known at all,
* nothing is rendered. A stock is only meaningful together with its age, so the time it was retrieved at is
* always shown as a tooltip.
*/
public function renderSupplierAvailableAmount(Part $context): string
{
$best_amount = null;
$retrieved_at = null;

foreach ($context->getOrderdetails(true) as $orderdetail) {
$amount = $orderdetail->getAvailableAmount();
if ($amount === null) {
continue;
}

if ($best_amount === null || $amount > $best_amount) {
$best_amount = $amount;
$retrieved_at = $orderdetail->getAvailableAmountUpdatedAt();
}
}

if ($best_amount === null) {
return '';
}

$title = $retrieved_at === null ? '' : $this->translator->trans(
'part.supplier.available_amount.updated_at',
['%datetime%' => $retrieved_at->format(\DateTimeInterface::ATOM)]
);

return sprintf('<span class="%s" title="%s">%s</span>',
$best_amount > 0 ? 'text-success' : 'text-danger',
htmlspecialchars($title),
$best_amount > 0
? htmlspecialchars($this->amountFormatter->format($best_amount, $context->getPartUnit()))
: htmlspecialchars($this->translator->trans('part.supplier.available_amount.out_of_stock'))
);
}
}
7 changes: 7 additions & 0 deletions src/DataTables/PartsDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ public function configure(DataTable $dataTable, array $options): void
'data' => fn(Part $context) => $this->partDataTableHelper->renderAmount($context),
'orderField' => 'amountSum'
])
->add('supplier_available_amount', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.supplier_available_amount'),
//The stock is not stored in a way we could sort by (it is spread over the orderdetails), so this
//column is purely informational
'orderable' => false,
'data' => fn(Part $context) => $this->partDataTableHelper->renderSupplierAvailableAmount($context),
])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
'data' => fn(Part $context, $value): string => $this->amountFormatter->format(
Expand Down
16 changes: 16 additions & 0 deletions src/DataTables/ProjectBomEntriesDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ public function configure(DataTable $dataTable, array $options): void
},
])

->add('supplier_available_amount', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.supplier_available_amount'),
//Hidden by default, as the stock of a supplier is only interesting while actually ordering the BOM
'visible' => false,
//The stock is spread over the orderdetails of the part, so it can not be sorted by in the database
'orderable' => false,
'data' => function (ProjectBOMEntry $context): string {
$part = $context->getPart();

return $part === null ? '' : $this->partDataTableHelper->renderSupplierAvailableAmount($part);
},
])

->add('mountnames', HTMLColumn::class, [
'label' => 'project.bom.mountnames',
'data' => function (ProjectBOMEntry $context) {
Expand Down Expand Up @@ -313,6 +326,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v
->addSelect('footprint')
->addSelect('manufacturer')
->addSelect('partCustomState')
->addSelect('orderdetails')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
Expand All @@ -321,6 +335,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->leftJoin('part.orderdetails', 'orderdetails')
->where('bom_entry.id IN (:ids)')
->setParameter('ids', $ids)
->addGroupBy('bom_entry')
Expand All @@ -331,6 +346,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
->addGroupBy('orderdetails')

->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
Expand Down
49 changes: 49 additions & 0 deletions src/Entity/PriceInformations/Orderdetail.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
#[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';

/**
* @var float|null The amount of parts the supplier had in stock, when this value was last retrieved from an info
* provider. Null means that the stock is unknown, which is something different than a stock of 0.
* This value is written by the info provider system only: a stock is volatile and is only meaningful together
* with the time it was retrieved at (see $available_amount_updated_at), so it can not be edited by hand.
*/
#[Assert\PositiveOrZero]
#[Groups(['extended', 'full', 'orderdetail:read'])]
#[ORM\Column(type: Types::FLOAT, nullable: true, options: ['default' => null])]
protected ?float $available_amount = null;

/**
* @var \DateTimeImmutable|null The time the available amount was retrieved from the info provider at, or null if
* no stock was ever retrieved for this orderdetail. Always set together with $available_amount.
*/
#[Groups(['extended', 'full', 'orderdetail:read'])]
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
protected ?\DateTimeImmutable $available_amount_updated_at = null;

/**
* @var Part|null The part with which this orderdetail is associated
*/
Expand Down Expand Up @@ -377,6 +396,36 @@ public function setObsolete(bool $new_obsolete): self
return $this;
}

/**
* Returns the amount of parts the supplier had in stock when it was last retrieved from the info provider,
* or null if no stock is known. Use getAvailableAmountUpdatedAt() to find out how old that value is.
*/
public function getAvailableAmount(): ?float
{
return $this->available_amount;
}

/**
* Returns the time the available amount was retrieved at, or null if no stock is known.
*/
public function getAvailableAmountUpdatedAt(): ?\DateTimeImmutable
{
return $this->available_amount_updated_at;
}

/**
* Sets the amount of parts the supplier has in stock. A stock is only meaningful together with the time it was
* retrieved at, so both are always set together: pass the time the value was retrieved from the provider at
* (defaults to now), or pass null as amount to state that the stock is unknown again.
*/
public function setAvailableAmount(?float $available_amount, ?\DateTimeImmutable $updated_at = null): self
{
$this->available_amount = $available_amount;
$this->available_amount_updated_at = $available_amount === null ? null : ($updated_at ?? new \DateTimeImmutable());

return $this;
}

/**
* Sets the custom product supplier URL for this order detail.
* Set this to "", if the function getSupplierProductURL should return the automatic generated URL.
Expand Down
9 changes: 9 additions & 0 deletions src/Services/EntityMergers/Mergers/PartMerger.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ private function mergeCollectionFields(Part $target, Part $other, array $context
if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) {
$t->setSupplierProductUrl($o->getSupplierProductUrl(false));
}
// The available amount is volatile information, so unlike the other fields the newer value wins here.
// A value of null means that the stock is unknown, which must not overwrite a known one, and an
// older value (e.g. from a part which has not been updated for a while) must not overwrite a newer one.
$other_stock_time = $o->getAvailableAmountUpdatedAt();
$target_stock_time = $t->getAvailableAmountUpdatedAt();
if ($o->getAvailableAmount() !== null && $other_stock_time !== null
&& ($target_stock_time === null || $other_stock_time >= $target_stock_time)) {
$t->setAvailableAmount($o->getAvailableAmount(), $other_stock_time);
}
// Merge price details: add new ones, update empty ones, keep existing non-empty ones
foreach ($o->getPricedetails() as $otherPrice) {
$found = false;
Expand Down
3 changes: 3 additions & 0 deletions src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public function __construct(
/** @var string|null An url to the product page of the vendor */
public ?string $product_url = null,
?bool $prices_include_vat = null,
/** @var float|null The amount the distributor currently has in stock. Null means that the stock is unknown,
* which is something different than a stock of 0. */
public ?float $available_amount = null,
)
{
//Ensure that the prices are PriceDTO instances
Expand Down
3 changes: 3 additions & 0 deletions src/Services/InfoProviderSystem/DTOtoEntityConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ public function convertPurchaseInfo(PurchaseInfoDTO $dto, Orderdetail $entity =
}

$entity->setPricesIncludesVAT($dto->prices_include_vat);
//The stock is stamped with the current time. The DTO can come from the info provider cache, so the value can
//be up to a few days older than that - which is precise enough to tell a fresh stock from a stale one.
$entity->setAvailableAmount($dto->available_amount);

return $entity;
}
Expand Down
12 changes: 10 additions & 2 deletions src/Services/InfoProviderSystem/PartInfoRetriever.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ final class PartInfoRetriever
private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 4; // 7 days

/**
* @var string The info provider DTOs are cached as serialized objects, and that cache outlives an update of
* Part-DB (it is not stored in the cache directory). Restoring an object of an older version into a class with
* new properties fails, as those properties stay uninitialized, so this marker is part of every cache key of a
* DTO and has to be increased whenever the structure of the DTOs changes.
*/
public const DTO_CACHE_VERSION = 'v2';

public function __construct(private readonly ProviderRegistry $provider_registry,
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache,
#[Autowire(param: "kernel.debug")]
Expand Down Expand Up @@ -102,7 +110,7 @@ protected function searchInProvider(InfoProviderInterface $provider, string $key
//Generate a hash for the options, to ensure that different options result in different cache entries
$options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR));

$cache_key = "search_{$provider->getProviderInfo()->key}_{$escaped_keyword}_{$options_hash}";
$cache_key = "search_".self::DTO_CACHE_VERSION."_{$provider->getProviderInfo()->key}_{$escaped_keyword}_{$options_hash}";

//If no_cache is set, bypass the cache and get fresh results from the provider
if ($no_cache) {
Expand Down Expand Up @@ -144,7 +152,7 @@ public function getDetails(string $provider_key, string $part_id, array $options

//Generate key and escape reserved characters from the provider id
$escaped_part_id = hash('xxh3', $part_id);
$cache_key = "details_{$provider_key}_{$escaped_part_id}_{$options_hash}";
$cache_key = "details_".self::DTO_CACHE_VERSION."_{$provider_key}_{$escaped_part_id}_{$options_hash}";

//Delete the cache entry if no_cache is set, to ensure that the next get call will fetch fresh data from the provider, instead of returning stale data from the cache.
if ($options[InfoProviderInterface::OPTION_NO_CACHE] ?? false) {
Expand Down
5 changes: 3 additions & 2 deletions src/Services/InfoProviderSystem/Providers/CanopyProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Settings\InfoProviderSystem\CanopySettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
Expand Down Expand Up @@ -86,7 +87,7 @@ private function productPageFromASIN(string $asin): string
*/
private function saveToCache(PartDetailDTO $part): void
{
$key = 'canopy_part_'.$part->provider_id;
$key = 'canopy_part_'.PartInfoRetriever::DTO_CACHE_VERSION.'_'.$part->provider_id;

$item = $this->partInfoCache->getItem($key);
$item->set($part);
Expand All @@ -101,7 +102,7 @@ private function saveToCache(PartDetailDTO $part): void
*/
private function getFromCache(string $id): ?PartDetailDTO
{
$key = 'canopy_part_'.$id;
$key = 'canopy_part_'.PartInfoRetriever::DTO_CACHE_VERSION.'_'.$id;

$item = $this->partInfoCache->getItem($key);
if ($item->isHit()) {
Expand Down
14 changes: 10 additions & 4 deletions src/Services/InfoProviderSystem/Providers/DigikeyProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,14 @@ public function getDetails(string $id, array $options = []): PartDetailDTO
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
$media = $this->mediaToDTOs($id);

// Get the price_breaks of the selected variation
// Get the price_breaks and the available stock of the selected variation
$price_breaks = [];
$available_amount = $product['QuantityAvailable'] ?? null;
foreach ($product['ProductVariations'] as $variation) {
if ($variation['DigiKeyProductNumber'] == $id) {
$price_breaks = $variation['StandardPricing'] ?? [];
//The stock of the selected packaging is more accurate than the one of the whole product
$available_amount = $variation['QuantityAvailableforPackageType'] ?? $available_amount;
break;
}
}
Expand All @@ -200,7 +203,7 @@ public function getDetails(string $id, array $options = []): PartDetailDTO
datasheets: $media['datasheets'],
images: $media['images'],
parameters: $parameters,
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl'], $available_amount),
);
}

Expand Down Expand Up @@ -277,9 +280,11 @@ private function parametersToDTOs(array $parameters, string|null &$footprint_nam
* @param array $price_breaks
* @param string $order_number
* @param string $product_url
* @param float|null $available_amount The amount Digikey has in stock, or null if that is unknown
* @return PurchaseInfoDTO[]
*/
private function pricingToDTOs(array $price_breaks, string $order_number, string $product_url): array
private function pricingToDTOs(array $price_breaks, string $order_number, string $product_url,
?float $available_amount = null): array
{
$prices = [];

Expand All @@ -288,7 +293,8 @@ private function pricingToDTOs(array $price_breaks, string $order_number, string
}

return [
new PurchaseInfoDTO(distributor_name: self::VENDOR_NAME, order_number: $order_number, prices: $prices, product_url: $product_url)
new PurchaseInfoDTO(distributor_name: self::VENDOR_NAME, order_number: $order_number, prices: $prices,
product_url: $product_url, available_amount: $available_amount)
];
}

Expand Down
Loading