diff --git a/migrations/Version20240905085300.php b/migrations/Version20240905085300.php
new file mode 100644
index 000000000..e29d967b3
--- /dev/null
+++ b/migrations/Version20240905085300.php
@@ -0,0 +1,48 @@
+addSql('ALTER TABLE parts ADD orderamount DOUBLE PRECISION NOT NULL DEFAULT 0, ADD orderDelivery DATETIME');
+ }
+
+ public function mySQLDown(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE `parts` DROP orderamount, DROP orderDelivery');
+ }
+
+ public function postgreSQLUp(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE parts ADD orderamount DOUBLE PRECISION NOT NULL DEFAULT 0, ADD orderDelivery timestamp');
+ }
+
+ public function postgreSQLDown(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE parts DROP orderamount, DROP orderDelivery');
+ }
+
+ public function sqLiteUp(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE parts ADD COLUMN orderamount DOUBLE PRECISION NOT NULL DEFAULT 0');
+ $this->addSql('ALTER TABLE parts ADD COLUMN orderDelivery DATETIME');
+ }
+
+ public function sqLiteDown(Schema $schema): void
+ {
+ $error;
+ // TODO: implement backwards migration for SQlite
+ }
+}
diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php
index c4c0e5260..56cde1e3d 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -229,6 +229,27 @@ public function markBulkImportComplete(Part $part, int $jobId, Request $request)
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]);
}
+ #[Route(path: '/{id}/delivered', name: 'part_delivered')]
+ public function delivered(Part $part, Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('edit', $part);
+
+ $partLot = $part->getPartLots()[0] ?? null;
+ if (!$partLot instanceof PartLot) {
+ $this->addFlash('error', 'part.delivered.error.no_lot');
+ return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
+ }
+
+ $partLot->setAmount($partLot->getAmount() + $part->getOrderAmount());
+ $part->setOrderAmount(0);
+ $part->setOrderDelivery(null);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
+ }
+
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
public function delete(Request $request, Part $part): RedirectResponse
{
diff --git a/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php b/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php
index 011824e50..6e824b661 100644
--- a/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php
+++ b/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php
@@ -31,7 +31,7 @@ class LessThanDesiredConstraint extends BooleanConstraint
public function __construct(?string $property = null, ?string $identifier = null, ?bool $default_value = null)
{
parent::__construct($property ?? '(
- SELECT COALESCE(SUM(ld_partLot.amount), 0.0)
+ SELECT COALESCE(SUM(ld_partLot.amount) + part.orderamount, 0.0)
FROM '.PartLot::class.' ld_partLot
WHERE ld_partLot.part = part.id
AND ld_partLot.instock_unknown = false
@@ -48,7 +48,7 @@ public function apply(QueryBuilder $queryBuilder): void
//If value is true, we want to filter for parts with stock < desired stock
if ($this->value) {
- $queryBuilder->andHaving( $this->property . ' < part.minamount');
+ $queryBuilder->andHaving($this->property . ' < part.minamount');
} else {
$queryBuilder->andHaving($this->property . ' >= part.minamount');
}
diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php
index a08293cac..464aee268 100644
--- a/src/DataTables/Filters/PartFilter.php
+++ b/src/DataTables/Filters/PartFilter.php
@@ -63,6 +63,8 @@ class PartFilter implements FilterInterface
public readonly TextConstraint $comment;
public readonly TagsConstraint $tags;
public readonly NumberConstraint $minAmount;
+ public readonly NumberConstraint $orderAmount;
+ public readonly DateTimeConstraint $orderDelivery;
public readonly BooleanConstraint $favorite;
public readonly BooleanConstraint $needsReview;
public readonly NumberConstraint $mass;
@@ -140,6 +142,8 @@ public function __construct(NodesListBuilder $nodesListBuilder)
$this->lastModified = new DateTimeConstraint('part.lastModified');
$this->minAmount = new NumberConstraint('part.minamount');
+ $this->orderAmount = new NumberConstraint('part.orderamount');
+ $this->orderDelivery = new DateTimeConstraint('part.orderDelivery');
/* We have to use an IntConstraint here because otherwise we get just an empty result list when applying the filter
This seems to be related to the fact, that PDO does not have an float parameter type and using string type does not work in this situation (at least in SQLite)
TODO: Find a better solution here
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index bcf640565..b3ff841be 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -175,6 +175,15 @@ public function configure(DataTable $dataTable, array $options): void
$context->getPartUnit()
)),
])
+ ->add('orderamount', TextColumn::class, [
+ 'label' => $this->translator->trans('part.table.orderamount'),
+ 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
+ $context->getPartUnit())),
+ ])
+ ->add('orderDelivery', LocaleDateTimeColumn::class, [
+ 'label' => $this->translator->trans('part.table.orderDelivery'),
+ 'timeFormat' => 'none',
+ ])
->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'),
'orderField' => 'NATSORT(_partUnit.name)',
diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php
index 5ac81b602..0209c520c 100644
--- a/src/Entity/Parts/Part.php
+++ b/src/Entity/Parts/Part.php
@@ -61,6 +61,7 @@
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
+use Doctrine\DBAL\Types\Types;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -111,9 +112,9 @@
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
#[ApiFilter(TagFilter::class, properties: ["tags"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
-#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
+#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount", "orderamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
-#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'orderDelivery', 'addedDate', 'lastModified'])]
class Part extends AttachmentContainingDBElement
{
use AdvancedPropertyTrait;
diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
index 065469b57..ac8135b8a 100644
--- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
+++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
@@ -23,6 +23,7 @@
namespace App\Entity\Parts\PartTraits;
use App\Entity\Parts\InfoProviderReference;
+use App\Validator\Constraints\Year2038BugWorkaround;
use App\Entity\Parts\PartCustomState;
use App\Validator\Constraints\ValidGTIN;
use Doctrine\DBAL\Types\Types;
@@ -60,6 +61,15 @@ trait AdvancedPropertyTrait
#[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $mass = null;
+ /**
+ * @var \DateTimeInterface|null Set a time when the new order will arive.
+ * Set to null, if there is no known date or no order.
+ */
+ #[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
+ #[Year2038BugWorkaround]
+ protected ?\DateTimeInterface $orderDelivery = null;
+
/**
* @var string|null The internal part number of the part
*/
@@ -161,6 +171,27 @@ public function setMass(?float $mass): self
return $this;
}
+ /**
+ * Gets the expected delivery date of the part. Returns null, if no delivery is due.
+ */
+ public function getOrderDelivery(): ?\DateTimeInterface
+ {
+ return $this->orderDelivery;
+ }
+
+ /**
+ * Sets the expected delivery date of the part. Set to null, if no delivery is due.
+ *
+ * @param \DateTimeInterface|null $orderDelivery The new delivery date
+ *
+ * @return $this
+ */
+ public function setOrderDelivery(?\DateTimeInterface $orderDelivery): self
+ {
+ $this->orderDelivery = $orderDelivery;
+ return $this;
+ }
+
/**
* Returns the internal part number of the part.
* @return string
diff --git a/src/Entity/Parts/PartTraits/InstockTrait.php b/src/Entity/Parts/PartTraits/InstockTrait.php
index 08b070f3a..e616b7c36 100644
--- a/src/Entity/Parts/PartTraits/InstockTrait.php
+++ b/src/Entity/Parts/PartTraits/InstockTrait.php
@@ -55,6 +55,14 @@ trait InstockTrait
#[ORM\Column(type: Types::FLOAT)]
protected float $minamount = 0;
+ /**
+ * @var float The number of already ordered units
+ */
+ #[Assert\PositiveOrZero]
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::FLOAT)]
+ protected float $orderamount = 0;
+
/**
* @var ?MeasurementUnit the unit in which the part's amount is measured
*/
@@ -137,6 +145,21 @@ public function getMinAmount(): float
return round($this->minamount);
}
+ /**
+ * Get the count of parts which are already ordered.
+ * If an integer-based part unit is selected, the value will be rounded to integers.
+ *
+ * @return float count of parts which are already ordered
+ */
+ public function getOrderAmount(): float
+ {
+ if ($this->useFloatAmount()) {
+ return $this->orderamount;
+ }
+
+ return round($this->orderamount);
+ }
+
/**
* Checks if this part uses the float amount .
* This setting is based on the part unit (see MeasurementUnit->isInteger()).
@@ -158,7 +181,7 @@ public function useFloatAmount(): bool
*/
public function isNotEnoughInstock(): bool
{
- return $this->getAmountSum() < $this->getMinAmount();
+ return ($this->getAmountSum() + $this->getOrderAmount()) < $this->getMinAmount();
}
/**
@@ -238,4 +261,19 @@ public function setMinAmount(float $new_minamount): self
return $this;
}
+
+ /**
+ * Set the amount of already ordered parts.
+ * See getPartUnit() for the associated unit.
+ *
+ * @param float $new_orderamount the new count of parts are already ordered
+ *
+ * @return $this
+ */
+ public function setOrderAmount(float $new_orderamount): self
+ {
+ $this->orderamount = $new_orderamount;
+
+ return $this;
+ }
}
diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php
index 25fe70b21..f892fccf5 100644
--- a/src/Form/Filters/PartFilterType.php
+++ b/src/Form/Filters/PartFilterType.php
@@ -221,6 +221,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'min' => 0,
]);
+ $builder->add('orderAmount', NumberConstraintType::class, [
+ 'label' => 'part.edit.orderstock',
+ 'min' => 0,
+ ]);
+
+ $builder->add('orderDelivery', DateTimeConstraintType::class, [
+ 'label' => 'part.edit.orderDelivery',
+ 'input_type' => DateType::class,
+ ]);
+
$builder->add('lotCount', NumberConstraintType::class, [
'label' => 'part.filter.lot_count',
'min' => 0,
diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php
index a31f24699..01078adab 100644
--- a/src/Form/Part/PartBaseType.php
+++ b/src/Form/Part/PartBaseType.php
@@ -48,6 +48,7 @@
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -135,6 +136,21 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'label' => 'part.edit.mininstock',
'measurement_unit' => $part->getPartUnit(),
])
+ ->add('orderAmount', SIUnitType::class, [
+ 'attr' => [
+ 'min' => 0,
+ 'placeholder' => 'part.editmininstock.placeholder',
+ ],
+ 'label' => 'part.edit.orderstock',
+ 'measurement_unit' => $part->getPartUnit(),
+ ])
+ ->add('orderDelivery', DateType::class, [
+ 'label' => 'part.edit.orderDelivery',
+ 'attr' => [],
+ 'widget' => 'single_text',
+ 'model_timezone' => 'UTC',
+ 'required' => false,
+ ])
->add('category', StructuralEntityType::class, [
'class' => Category::class,
'allow_add' => $this->security->isGranted('@categories.create'),
diff --git a/src/Serializer/PartNormalizer.php b/src/Serializer/PartNormalizer.php
index 8486a634a..2f608d536 100644
--- a/src/Serializer/PartNormalizer.php
+++ b/src/Serializer/PartNormalizer.php
@@ -145,6 +145,9 @@ public function denormalize($data, string $type, ?string $format = null, array $
if (empty($data['minamount'])) {
$data['minamount'] = 0.0;
}
+ if (empty($data['orderamount'])) {
+ $data['orderamount'] = 0.0;
+ }
$context[self::ALREADY_CALLED] = true;
diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php
index 91e271cc0..b4343d737 100644
--- a/src/Services/EntityURLGenerator.php
+++ b/src/Services/EntityURLGenerator.php
@@ -84,6 +84,7 @@ public function getURL(mixed $entity, string $type): string
'delete' => $this->deleteURL($entity),
'file_download' => $this->downloadURL($entity),
'file_view' => $this->viewURL($entity),
+ 'delivered' => $this->deliveredURL($entity),
default => throw new InvalidArgumentException('Method is not supported!'),
};
}
@@ -171,6 +172,11 @@ public function viewURL(Attachment $entity): string
throw new \RuntimeException('Attachment has no internal nor external path!');
}
+ public function deliveredURL(Part $entity): string
+ {
+ return $this->urlGenerator->generate('part_delivered', ['id' => $entity->getID()]);
+ }
+
public function downloadURL($entity): string
{
if (!($entity instanceof Attachment)) {
diff --git a/src/Services/LabelSystem/SandboxedTwigFactory.php b/src/Services/LabelSystem/SandboxedTwigFactory.php
index fb3b63620..3383df2ec 100644
--- a/src/Services/LabelSystem/SandboxedTwigFactory.php
+++ b/src/Services/LabelSystem/SandboxedTwigFactory.php
@@ -136,7 +136,7 @@ final class SandboxedTwigFactory
Supplier::class => ['getShippingCosts', 'getDefaultCurrency'],
Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference',
'getDescription', 'getComment', 'isFavorite', 'getCategory', 'getFootprint',
- 'getPartLots', 'getPartUnit', 'getPartCustomState', 'useFloatAmount', 'getMinAmount', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum',
+ 'getPartLots', 'getPartUnit', 'getPartCustomState', 'useFloatAmount', 'getMinAmount', 'getOrderAmount', 'getOrderDelivery', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum',
'getManufacturerProductUrl', 'getCustomProductURL', 'getManufacturingStatus', 'getManufacturer',
'getManufacturerProductNumber', 'getOrderdetails', 'isObsolete',
'getParameters', 'getGroupedParameters',
diff --git a/templates/parts/edit/_main.html.twig b/templates/parts/edit/_main.html.twig
index 06c711061..35b2b5bca 100644
--- a/templates/parts/edit/_main.html.twig
+++ b/templates/parts/edit/_main.html.twig
@@ -10,6 +10,8 @@
{{ form_row(form.category) }}
{{ form_row(form.tags) }}
{{ form_row(form.minAmount) }}
+{{ form_row(form.orderAmount) }}
+{{ form_row(form.orderDelivery) }}
{{ form_row(form.footprint) }}
diff --git a/templates/parts/info/_main_infos.html.twig b/templates/parts/info/_main_infos.html.twig
index bced5624b..f30b74073 100644
--- a/templates/parts/info/_main_infos.html.twig
+++ b/templates/parts/info/_main_infos.html.twig
@@ -76,6 +76,17 @@
{% if part.expiredAmountSum > 0 %}
(+{{ part.expiredAmountSum }})
{% endif %}
+ {% if part.orderAmount > 0 %}
+ (+
+ {{ part.orderAmount | format_amount(part.partUnit) }}
+ {% if part.orderDelivery %}
+ @
+
+ {{ part.orderDelivery | format_date() }}
+
+ {% endif %}
+ )
+ {% endif %}
/
{{ part.minAmount | format_amount(part.partUnit) }}
diff --git a/templates/parts/info/_tools.html.twig b/templates/parts/info/_tools.html.twig
index 455d51b7d..0c9fe7cbd 100644
--- a/templates/parts/info/_tools.html.twig
+++ b/templates/parts/info/_tools.html.twig
@@ -39,6 +39,14 @@
{% endif %}
+{% if is_granted('edit', part) %}
+
+
+
+ {% trans %}part.delivered.btn{% endtrans %}
+
+{% endif %}
+