From 276370b17212188e90507c9b4a29154217d5c040 Mon Sep 17 00:00:00 2001 From: killecaptron <203002577+killecaptron@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:12:29 +0200 Subject: [PATCH 1/2] Version the cache keys of the info provider DTOs The info provider system caches the DTOs it received from a provider as serialized objects, in a cache pool which is not part of the cache directory and therefore survives an update of Part-DB. Whenever a DTO class gains a property, unserializing an object which was cached by an older version leaves that property uninitialized, so the first access to it fails with a typed property error until the cache happens to expire (up to four days later). Add a version marker to every cache key of a DTO, which has to be increased whenever the structure of the DTOs changes: old entries are then simply never read again and expire on their own. Co-Authored-By: Claude Opus 5 --- .../InfoProviderSystem/PartInfoRetriever.php | 12 ++++++++++-- .../InfoProviderSystem/Providers/CanopyProvider.php | 5 +++-- .../Providers/OctopartProvider.php | 5 +++-- .../Providers/TrustedPartsProvider.php | 3 ++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index be3decdd5..c3cf9781e 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -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")] @@ -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) { @@ -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) { diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php index 6ec71a013..9e2ee9b7b 100644 --- a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -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; @@ -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); @@ -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()) { diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php index 14fdbfa51..c5985f5bf 100644 --- a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -30,6 +30,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\Services\OAuth\OAuthTokenManager; use App\Settings\InfoProviderSystem\OctopartSettings; use Psr\Cache\CacheItemPoolInterface; @@ -215,7 +216,7 @@ private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus */ private function saveToCache(PartDetailDTO $part): void { - $key = 'octopart_part_'.$part->provider_id; + $key = 'octopart_part_'.PartInfoRetriever::DTO_CACHE_VERSION.'_'.$part->provider_id; $item = $this->partInfoCache->getItem($key); $item->set($part); @@ -230,7 +231,7 @@ private function saveToCache(PartDetailDTO $part): void */ private function getFromCache(string $id): ?PartDetailDTO { - $key = 'octopart_part_'.$id; + $key = 'octopart_part_'.PartInfoRetriever::DTO_CACHE_VERSION.'_'.$id; $item = $this->partInfoCache->getItem($key); if ($item->isHit()) { diff --git a/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php b/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php index 43bc7f0c7..1ee34949d 100644 --- a/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php @@ -30,6 +30,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\TrustedPartsSettings; use Psr\Cache\CacheItemPoolInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface; @@ -436,6 +437,6 @@ private function getFromCache(string $id): ?PartDetailDTO private function cacheKey(string $id): string { //The IDs contain characters which are not allowed in cache keys, so we hash them - return 'trustedparts_part_'.hash('xxh3', $id); + return 'trustedparts_part_'.PartInfoRetriever::DTO_CACHE_VERSION.'_'.hash('xxh3', $id); } } From 7908a52e98728c6b338a4fc9df7f656efe711669 Mon Sep 17 00:00:00 2001 From: killecaptron <203002577+killecaptron@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:19:48 +0200 Subject: [PATCH 2/2] Store the stock a supplier has for a part, as reported by the info providers Most info providers tell how many parts a distributor currently has on hand, but Part-DB threw that information away, even though it is one of the things one wants to see when deciding where to order a part from. Orderdetails now carry an available amount, filled by the info provider system from Digikey, Mouser and TrustedParts. Null means the stock is unknown, which is deliberately different from a stock of 0 ("out of stock"). A stock is volatile and therefore only meaningful together with its age, so the time the value was retrieved at is always stored along with it, and shown wherever the stock is shown. For the same reason the value is not editable by hand: it is written by the info provider system only, and the merger keeps the newer of two values instead of the target's. The stock is shown in the ordering information of a part, and as an optional column in the parts table and in the project BOM. Co-Authored-By: Claude Opus 5 --- migrations/Version20260907090000.php | 50 ++++++++ .../Helpers/PartDataTableHelper.php | 41 +++++++ src/DataTables/PartsDataTable.php | 7 ++ src/DataTables/ProjectBomEntriesDataTable.php | 16 +++ src/Entity/PriceInformations/Orderdetail.php | 49 ++++++++ .../EntityMergers/Mergers/PartMerger.php | 9 ++ .../DTOs/PurchaseInfoDTO.php | 3 + .../DTOtoEntityConverter.php | 3 + .../Providers/DigikeyProvider.php | 14 ++- .../Providers/MouserProvider.php | 21 +++- .../Providers/TrustedPartsProvider.php | 4 + .../BehaviorSettings/PartTableColumns.php | 1 + templates/parts/info/_order_infos.html.twig | 16 +++ .../PriceInformations/OrderdetailTest.php | 75 ++++++++++++ .../EntityMergers/Mergers/PartMergerTest.php | 108 ++++++++++++++++++ .../DTOtoEntityConverterTest.php | 19 +++ .../Providers/TrustedPartsProviderTest.php | 3 + translations/messages.en.xlf | 30 +++++ 18 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 migrations/Version20260907090000.php create mode 100644 tests/Entity/PriceInformations/OrderdetailTest.php diff --git a/migrations/Version20260907090000.php b/migrations/Version20260907090000.php new file mode 100644 index 000000000..18853000f --- /dev/null +++ b/migrations/Version20260907090000.php @@ -0,0 +1,50 @@ +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'); + } +} diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php index 065e1201c..2a2c6f25e 100644 --- a/src/DataTables/Helpers/PartDataTableHelper.php +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -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('%s', + $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')) + ); + } } diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index c15468de5..3e37ea0b2 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -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( diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php index 0f7af87c9..7248ca67d 100644 --- a/src/DataTables/ProjectBomEntriesDataTable.php +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -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) { @@ -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') @@ -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') @@ -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) diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 73797f9d2..57ecfe623 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -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 */ @@ -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. diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php index 23a9df1df..d1472f7ab 100644 --- a/src/Services/EntityMergers/Mergers/PartMerger.php +++ b/src/Services/EntityMergers/Mergers/PartMerger.php @@ -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; diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php index ff03c823d..24c4d0fda 100644 --- a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -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 diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 85f84bf36..eff0fe997 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -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; } diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index 78763a509..b27cb1115 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -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; } } @@ -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), ); } @@ -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 = []; @@ -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) ]; } diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php index beabf6091..42c7e398d 100644 --- a/src/Services/InfoProviderSystem/Providers/MouserProvider.php +++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php @@ -260,7 +260,7 @@ private function responseToDTOArray(ResponseInterface $response): array datasheets: $this->parseDataSheets($product['DataSheetUrl'] ?? null, $product['MouserPartNumber'] ?? null), vendor_infos: $this->pricingToDTOs($product['PriceBreaks'] ?? [], $product['MouserPartNumber'], - $product['ProductDetailUrl']), + $product['ProductDetailUrl'], $this->parseAvailableAmount($product['AvailabilityInStock'] ?? null)), mass: $mass, ); } @@ -314,9 +314,11 @@ private function mapCurrencyCode(string $currency): string * @param array $price_breaks * @param string $order_number * @param string $product_url + * @param float|null $available_amount The amount Mouser 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 = []; @@ -331,10 +333,23 @@ private function pricingToDTOs(array $price_breaks, string $order_number, string return [ new PurchaseInfoDTO(distributor_name: self::DISTRIBUTOR_NAME, order_number: $order_number, prices: $prices, - product_url: $product_url) + product_url: $product_url, available_amount: $available_amount) ]; } + /** + * Mouser returns the stock as a string (which can also be empty or contain extra characters). + * @return float|null The parsed amount, or null if Mouser did not tell us a usable value + */ + private function parseAvailableAmount(string|int|float|null $availability): ?float + { + if ($availability === null || $availability === '') { + return null; + } + + return is_numeric($availability) ? (float) $availability : null; + } + /* Converts the product status from the MOUSER API to the manufacturing status used in Part-DB: Factory Special Order - Ordine speciale in fabbrica diff --git a/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php b/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php index 1ee34949d..e91264c7e 100644 --- a/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TrustedPartsProvider.php @@ -349,12 +349,16 @@ private function partResultToDTO(array $part): PartDetailDTO $order_number = $mpn; } + //QuantityOnHand is null if the distributor does not disclose the exact number + $available_amount = $offer['Stock']['QuantityOnHand'] ?? null; + $orderinfos[] = new PurchaseInfoDTO( distributor_name: $distributor_name, order_number: $order_number, prices: $prices, product_url: $product_url, prices_include_vat: false, + available_amount: $available_amount !== null ? (float) $available_amount : null, ); } } diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php index 32f6100bb..e2005ea12 100644 --- a/src/Settings/BehaviorSettings/PartTableColumns.php +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -38,6 +38,7 @@ enum PartTableColumns : string implements TranslatableInterface case MANUFACTURER = "manufacturer"; case LOCATION = "storage_location"; case AMOUNT = "amount"; + case SUPPLIER_AVAILABLE_AMOUNT = "supplier_available_amount"; case MIN_AMOUNT = "minamount"; case PART_UNIT = "partUnit"; case ADDED_DATE = "addedDate"; diff --git a/templates/parts/info/_order_infos.html.twig b/templates/parts/info/_order_infos.html.twig index ef62d6f30..aec18e340 100644 --- a/templates/parts/info/_order_infos.html.twig +++ b/templates/parts/info/_order_infos.html.twig @@ -6,6 +6,7 @@ {% trans %}part.supplier.name{% endtrans %} {% trans %}part.supplier.partnr{% endtrans %} + {% trans %}part.supplier.available_amount{% endtrans %} @@ -22,6 +23,21 @@ {{ order.supplierPartNr }} {% endif %} + + {% if order.availableAmount is null %} + {% trans %}part.supplier.available_amount.unknown{% endtrans %} + {% else %} + {# A stock is only meaningful together with its age, so always show when it was retrieved #} + + {% if order.availableAmount > 0 %} + {{ order.availableAmount | format_amount(part.partUnit) }} + {% else %} + {% trans %}part.supplier.available_amount.out_of_stock{% endtrans %} + {% endif %} + + {% endif %} + {% if order.pricedetails is not empty %} diff --git a/tests/Entity/PriceInformations/OrderdetailTest.php b/tests/Entity/PriceInformations/OrderdetailTest.php new file mode 100644 index 000000000..52aadbb50 --- /dev/null +++ b/tests/Entity/PriceInformations/OrderdetailTest.php @@ -0,0 +1,75 @@ +. + */ +namespace App\Tests\Entity\PriceInformations; + +use App\Entity\PriceInformations\Orderdetail; +use PHPUnit\Framework\TestCase; + +final class OrderdetailTest extends TestCase +{ + public function testAvailableAmountIsUnknownByDefault(): void + { + $orderdetail = new Orderdetail(); + + $this->assertNull($orderdetail->getAvailableAmount()); + $this->assertNull($orderdetail->getAvailableAmountUpdatedAt()); + } + + public function testSettingTheAvailableAmountStampsTheCurrentTime(): void + { + $before = new \DateTimeImmutable(); + $orderdetail = (new Orderdetail())->setAvailableAmount(100.0); + + $this->assertSame(100.0, $orderdetail->getAvailableAmount()); + $this->assertNotNull($orderdetail->getAvailableAmountUpdatedAt()); + $this->assertGreaterThanOrEqual($before, $orderdetail->getAvailableAmountUpdatedAt()); + } + + public function testTheTimeOfTheAvailableAmountCanBeGiven(): void + { + //The value can come from an info provider cache, so the caller can tell when it was actually retrieved + $retrieved_at = new \DateTimeImmutable('2026-09-01 12:00:00'); + $orderdetail = (new Orderdetail())->setAvailableAmount(100.0, $retrieved_at); + + $this->assertEquals($retrieved_at, $orderdetail->getAvailableAmountUpdatedAt()); + } + + public function testAStockOfZeroIsKeptAsKnownStock(): void + { + //A stock of 0 is a known stock ("out of stock"), which is something different from an unknown stock + $orderdetail = (new Orderdetail())->setAvailableAmount(0.0); + + $this->assertSame(0.0, $orderdetail->getAvailableAmount()); + $this->assertNotNull($orderdetail->getAvailableAmountUpdatedAt()); + } + + public function testResettingTheAvailableAmountClearsTheTime(): void + { + $orderdetail = (new Orderdetail())->setAvailableAmount(100.0); + + $orderdetail->setAvailableAmount(null); + + $this->assertNull($orderdetail->getAvailableAmount()); + $this->assertNull($orderdetail->getAvailableAmountUpdatedAt()); + } +} diff --git a/tests/Services/EntityMergers/Mergers/PartMergerTest.php b/tests/Services/EntityMergers/Mergers/PartMergerTest.php index 84551019d..2b8a017ea 100644 --- a/tests/Services/EntityMergers/Mergers/PartMergerTest.php +++ b/tests/Services/EntityMergers/Mergers/PartMergerTest.php @@ -33,6 +33,7 @@ use App\Entity\Parts\PartAssociation; use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; +use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Orderdetail; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; @@ -357,4 +358,111 @@ public function testSupports() $this->assertFalse($this->merger->supports(new \stdClass(), new Part())); $this->assertTrue($this->merger->supports(new Part(), new Part())); } + + public function testMergeOrderdetailsUpdatesTheAvailableAmount(): void + { + $supplier = new Supplier(); + $supplier->setName('TestSupplier'); + + $target = new Part(); + $target_orderdetail = new Orderdetail(); + $target_orderdetail->setSupplier($supplier); + $target_orderdetail->setSupplierpartnr('1234'); + $target_orderdetail->setAvailableAmount(10.0); + $target->addOrderdetail($target_orderdetail); + + $other = new Part(); + $other_orderdetail = new Orderdetail(); + $other_orderdetail->setSupplier($supplier); + $other_orderdetail->setSupplierpartnr('1234'); + $other_orderdetail->setAvailableAmount(500.0); + $other->addOrderdetail($other_orderdetail); + + $merged = $this->merger->merge($target, $other); + + //The stock is volatile, so the newer value has to win here + $this->assertCount(1, $merged->getOrderdetails()); + $this->assertSame(500.0, $merged->getOrderdetails()->first()->getAvailableAmount()); + } + + public function testMergeOrderdetailsKeepsAvailableAmountIfUnknown(): void + { + $supplier = new Supplier(); + $supplier->setName('TestSupplier'); + + $target = new Part(); + $target_orderdetail = new Orderdetail(); + $target_orderdetail->setSupplier($supplier); + $target_orderdetail->setSupplierpartnr('1234'); + $target_orderdetail->setAvailableAmount(10.0); + $target->addOrderdetail($target_orderdetail); + + $other = new Part(); + $other_orderdetail = new Orderdetail(); + $other_orderdetail->setSupplier($supplier); + $other_orderdetail->setSupplierpartnr('1234'); + //The other side does not know the stock + $other->addOrderdetail($other_orderdetail); + + $merged = $this->merger->merge($target, $other); + + //An unknown stock must not overwrite a known one + $this->assertSame(10.0, $merged->getOrderdetails()->first()->getAvailableAmount()); + } + + public function testMergeOrderdetailsKeepsTheNewerAvailableAmount(): void + { + $supplier = new Supplier(); + $supplier->setName('TestSupplier'); + + $now = new \DateTimeImmutable(); + + $target = new Part(); + $target_orderdetail = new Orderdetail(); + $target_orderdetail->setSupplier($supplier); + $target_orderdetail->setSupplierpartnr('1234'); + $target_orderdetail->setAvailableAmount(10.0, $now); + $target->addOrderdetail($target_orderdetail); + + $other = new Part(); + $other_orderdetail = new Orderdetail(); + $other_orderdetail->setSupplier($supplier); + $other_orderdetail->setSupplierpartnr('1234'); + //The other side knows a stock, but an older one than the target + $other_orderdetail->setAvailableAmount(500.0, $now->modify('-1 day')); + $other->addOrderdetail($other_orderdetail); + + $merged = $this->merger->merge($target, $other); + + //An older stock must not overwrite a newer one + $this->assertSame(10.0, $merged->getOrderdetails()->first()->getAvailableAmount()); + $this->assertEquals($now, $merged->getOrderdetails()->first()->getAvailableAmountUpdatedAt()); + } + + public function testMergeOrderdetailsTakesOverTheTimeOfTheAvailableAmount(): void + { + $supplier = new Supplier(); + $supplier->setName('TestSupplier'); + + $retrieved_at = new \DateTimeImmutable('2026-09-01 12:00:00'); + + $target = new Part(); + $target_orderdetail = new Orderdetail(); + $target_orderdetail->setSupplier($supplier); + $target_orderdetail->setSupplierpartnr('1234'); + $target->addOrderdetail($target_orderdetail); + + $other = new Part(); + $other_orderdetail = new Orderdetail(); + $other_orderdetail->setSupplier($supplier); + $other_orderdetail->setSupplierpartnr('1234'); + $other_orderdetail->setAvailableAmount(500.0, $retrieved_at); + $other->addOrderdetail($other_orderdetail); + + $merged = $this->merger->merge($target, $other); + + //A stock is only meaningful together with its age, so the time has to travel with the value + $this->assertSame(500.0, $merged->getOrderdetails()->first()->getAvailableAmount()); + $this->assertEquals($retrieved_at, $merged->getOrderdetails()->first()->getAvailableAmountUpdatedAt()); + } } diff --git a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php index 8ea6c71a6..413afbf10 100644 --- a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php +++ b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php @@ -115,6 +115,7 @@ public function testConvertPurchaseInfo(): void prices: $prices, product_url: 'https://example.com', prices_include_vat: true, + available_amount: 1234.0, ); $entity = $this->service->convertPurchaseInfo($dto); @@ -123,6 +124,24 @@ public function testConvertPurchaseInfo(): void $this->assertSame($dto->order_number, $entity->getSupplierPartNr()); $this->assertEquals($dto->product_url, $entity->getSupplierProductUrl()); $this->assertTrue($dto->prices_include_vat); + $this->assertSame(1234.0, $entity->getAvailableAmount()); + //A stock is only meaningful together with its age, so the time it was retrieved at is stamped along with it + $this->assertNotNull($entity->getAvailableAmountUpdatedAt()); + } + + public function testConvertPurchaseInfoWithoutAvailableAmount(): void + { + $dto = new PurchaseInfoDTO( + distributor_name: 'TestDistributor', + order_number: 'TestOrderNumber', + prices: [], + ); + + $entity = $this->service->convertPurchaseInfo($dto); + + //If the provider does not know the stock, it must stay null (which is different from a stock of 0) + $this->assertNull($entity->getAvailableAmount()); + $this->assertNull($entity->getAvailableAmountUpdatedAt()); } public function testConvertFileWithName(): void diff --git a/tests/Services/InfoProviderSystem/Providers/TrustedPartsProviderTest.php b/tests/Services/InfoProviderSystem/Providers/TrustedPartsProviderTest.php index 5126c24a5..14c4ea9a5 100644 --- a/tests/Services/InfoProviderSystem/Providers/TrustedPartsProviderTest.php +++ b/tests/Services/InfoProviderSystem/Providers/TrustedPartsProviderTest.php @@ -202,12 +202,15 @@ public function testSearchByKeywordMapsOffers(): void $this->assertSame(1.0, $orderinfos[0]->prices[0]->minimum_discount_amount); $this->assertSame('0.22', $orderinfos[0]->prices[0]->price); $this->assertSame('EUR', $orderinfos[0]->prices[0]->currency_iso_code); + $this->assertSame(1000.0, $orderinfos[0]->available_amount); //If a distributor does not provide an own part number, the MPN is used instead $this->assertSame('Mouser Electronics', $orderinfos[1]->distributor_name); $this->assertSame('LM358DR', $orderinfos[1]->order_number); //Offers without pricing information are still shown $this->assertSame([], $orderinfos[1]->prices); + //A stock of 0 must be kept as 0 and not turned into "unknown" + $this->assertSame(0.0, $orderinfos[1]->available_amount); } public function testSearchByKeywordRespectsSearchLimit(): void diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f7a9d4e9f..88fbaf983 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15110,5 +15110,35 @@ Buerklin-API Authentication server: Edit BOM entry #%id% of project + + + part.supplier.available_amount + Available + + + + + part.supplier.available_amount.unknown + Unknown + + + + + part.supplier.available_amount.out_of_stock + Out of stock + + + + + part.supplier.available_amount.updated_at + Stock retrieved from the info provider at: %datetime% + + + + + part.table.supplier_available_amount + Available at supplier + +