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/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/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/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..e91264c7e 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; @@ -348,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, ); } } @@ -436,6 +441,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); } } 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 @@