From 7fd7697c02e483ed75b16cbba48fffe0a53ef8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 14:17:58 +0100 Subject: [PATCH 01/21] Added GTIN fields and others to DB --- migrations/Version20260208131116.php | 130 ++++++++++++++++++ src/Entity/Attachments/Attachment.php | 2 +- src/Entity/Attachments/AttachmentType.php | 6 + src/Entity/Parts/Part.php | 1 + src/Entity/Parts/PartLot.php | 28 ++++ .../PartTraits/AdvancedPropertyTrait.php | 30 ++++ src/Entity/PriceInformations/Pricedetail.php | 29 ++++ 7 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260208131116.php diff --git a/migrations/Version20260208131116.php b/migrations/Version20260208131116.php new file mode 100644 index 00000000..7ffc47a8 --- /dev/null +++ b/migrations/Version20260208131116.php @@ -0,0 +1,130 @@ +addSql('ALTER TABLE attachment_types ADD allowed_targets LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL'); + $this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)'); + $this->addSql('ALTER TABLE pricedetails ADD include_vat TINYINT DEFAULT NULL'); + } + + public function mySQLDown(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE `attachment_types` DROP allowed_targets'); + $this->addSql('DROP INDEX parts_idx_gtin ON `parts`'); + $this->addSql('ALTER TABLE `parts` DROP gtin'); + $this->addSql('ALTER TABLE part_lots DROP last_stocktake_at'); + $this->addSql('ALTER TABLE `pricedetails` DROP include_vat'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('ALTER TABLE attachment_types ADD COLUMN allowed_targets CLOB DEFAULT NULL'); + $this->addSql('ALTER TABLE part_lots ADD COLUMN last_stocktake_at DATETIME DEFAULT NULL'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts'); + $this->addSql('DROP TABLE parts'); + $this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, gtin VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE INDEX parts_idx_name ON parts (name)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)'); + $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)'); + $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)'); + $this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)'); + $this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)'); + $this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)'); + $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)'); + $this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)'); + $this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)'); + $this->addSql('ALTER TABLE pricedetails ADD COLUMN include_vat BOOLEAN DEFAULT NULL'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM "attachment_types"'); + $this->addSql('DROP TABLE "attachment_types"'); + $this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, filetype_filter CLOB NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "attachment_types" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM __temp__attachment_types'); + $this->addSql('DROP TABLE __temp__attachment_types'); + $this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)'); + $this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)'); + $this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)'); + $this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots'); + $this->addSql('DROP TABLE part_lots'); + $this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots'); + $this->addSql('DROP TABLE __temp__part_lots'); + $this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)'); + $this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)'); + $this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)'); + $this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)'); + $this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)'); + $this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"'); + $this->addSql('DROP TABLE "parts"'); + $this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)'); + $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)'); + $this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)'); + $this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)'); + $this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)'); + $this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)'); + $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)'); + $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added, id_currency, orderdetails_id FROM "pricedetails"'); + $this->addSql('DROP TABLE "pricedetails"'); + $this->addSql('CREATE TABLE "pricedetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, price NUMERIC(11, 5) NOT NULL, price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES "orderdetails" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "pricedetails" (id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added, id_currency, orderdetails_id) SELECT id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added, id_currency, orderdetails_id FROM __temp__pricedetails'); + $this->addSql('DROP TABLE __temp__pricedetails'); + $this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON "pricedetails" (id_currency)'); + $this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON "pricedetails" (orderdetails_id)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount ON "pricedetails" (min_discount_quantity)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON "pricedetails" (min_discount_quantity, price_related_quantity)'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql('ALTER TABLE attachment_types ADD allowed_targets TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)'); + $this->addSql('ALTER TABLE pricedetails ADD include_vat BOOLEAN DEFAULT NULL'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE "attachment_types" DROP allowed_targets'); + $this->addSql('ALTER TABLE part_lots DROP last_stocktake_at'); + $this->addSql('DROP INDEX parts_idx_gtin'); + $this->addSql('ALTER TABLE "parts" DROP gtin'); + $this->addSql('ALTER TABLE "pricedetails" DROP include_vat'); + } +} diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index 259785cb..ac625e92 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -97,7 +97,7 @@ use function in_array; #[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)] abstract class Attachment extends AbstractNamedDBElement { - private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class, + final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class, 'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class, 'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class, diff --git a/src/Entity/Attachments/AttachmentType.php b/src/Entity/Attachments/AttachmentType.php index 22333c16..273e800a 100644 --- a/src/Entity/Attachments/AttachmentType.php +++ b/src/Entity/Attachments/AttachmentType.php @@ -134,6 +134,12 @@ class AttachmentType extends AbstractStructuralDBElement #[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)] protected Collection $attachments_with_type; + /** + * @var array|null A list of allowed targets where this attachment type can be assigned to. + */ + #[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)] + protected ?array $allowed_targets = null; + #[Groups(['attachment_type:read'])] protected ?\DateTimeImmutable $addedDate = null; #[Groups(['attachment_type:read'])] diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index d0a279e3..5ac81b60 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -80,6 +80,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')] #[ORM\Index(columns: ['name'], name: 'parts_idx_name')] #[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')] +#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')] #[ApiResource( operations: [ new Get(normalizationContext: [ diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php index d893e6de..53ecd3d5 100644 --- a/src/Entity/Parts/PartLot.php +++ b/src/Entity/Parts/PartLot.php @@ -171,6 +171,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named #[Length(max: 255)] protected ?string $user_barcode = null; + /** + * @var \DateTimeImmutable|null The date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet. + */ + #[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])] + #[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: true)] + #[Year2038BugWorkaround] + protected ?\DateTimeImmutable $last_stocktake_at = null; + public function __clone() { if ($this->id) { @@ -391,6 +399,26 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named return $this; } + /** + * Returns the date when the last stocktake was performed for this part lot. Returns null, if no stocktake was performed yet. + * @return \DateTimeImmutable|null + */ + public function getLastStocktakeAt(): ?\DateTimeImmutable + { + return $this->last_stocktake_at; + } + + /** + * Sets the date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet. + * @param \DateTimeImmutable|null $last_stocktake_at + * @return $this + */ + public function setLastStocktakeAt(?\DateTimeImmutable $last_stocktake_at): self + { + $this->last_stocktake_at = $last_stocktake_at; + return $this; + } + #[Assert\Callback] diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index 2cee7f1a..38130f0d 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -84,6 +84,14 @@ trait AdvancedPropertyTrait #[ORM\JoinColumn(name: 'id_part_custom_state')] protected ?PartCustomState $partCustomState = null; + /** + * @var string|null The GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code + */ + #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] + #[ORM\Column(type: Types::STRING, nullable: true)] + #[Length(max: 14)] + protected ?string $gtin = null; + /** * Checks if this part is marked, for that it needs further review. */ @@ -211,4 +219,26 @@ trait AdvancedPropertyTrait return $this; } + + /** + * Gets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code. + * Returns null if no GTIN is set. + */ + public function getGtin(): ?string + { + return $this->gtin; + } + + /** + * Sets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code. + * + * @param string|null $gtin The new GTIN of the part + * + * @return $this + */ + public function setGtin(?string $gtin): self + { + $this->gtin = $gtin; + return $this; + } } diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php index 86a7bcd5..b98ee056 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -121,6 +121,13 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface #[Groups(['pricedetail:read:standalone', 'pricedetail:write'])] protected ?Orderdetail $orderdetail = null; + /** + * @var bool|null Whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. + */ + #[ORM\Column(type: Types::BOOLEAN, nullable: true)] + #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])] + protected ?bool $include_vat = null; + public function __construct() { $this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION); @@ -360,4 +367,26 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface return $this; } + + /** + * Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. + * @return bool|null + */ + public function getIncludeVat(): ?bool + { + return $this->include_vat; + } + + /** + * Sets whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. + * @param bool|null $include_vat + * @return $this + */ + public function setIncludeVat(?bool $include_vat): self + { + $this->include_vat = $include_vat; + return $this; + } + + } From 57c8368b5e9f02154f33940f2625d073ae6a172c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 14:44:56 +0100 Subject: [PATCH 02/21] Allow to edit the GTIN property of a part and validate the GTIN --- composer.json | 1 + composer.lock | 50 ++++++++++++- .../PartTraits/AdvancedPropertyTrait.php | 3 +- src/Form/Part/PartBaseType.php | 8 ++- src/Validator/Constraints/ValidGTIN.php | 35 +++++++++ .../Constraints/ValidGTINValidator.php | 54 ++++++++++++++ templates/parts/edit/_advanced.html.twig | 3 +- .../Constraints/ValidGTINValidatorTest.php | 72 +++++++++++++++++++ translations/messages.en.xlf | 8 ++- translations/validators.en.xlf | 8 ++- 10 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/Validator/Constraints/ValidGTIN.php create mode 100644 src/Validator/Constraints/ValidGTINValidator.php create mode 100644 tests/Validator/Constraints/ValidGTINValidatorTest.php diff --git a/composer.json b/composer.json index 36dd461e..dd12097c 100644 --- a/composer.json +++ b/composer.json @@ -90,6 +90,7 @@ "symfony/yaml": "7.4.*", "symplify/easy-coding-standard": "^12.5.20", "tecnickcom/tc-lib-barcode": "^2.1.4", + "tiendanube/gtinvalidation": "^1.0", "twig/cssinliner-extra": "^3.0", "twig/extra-bundle": "^3.8", "twig/html-extra": "^3.8", diff --git a/composer.lock b/composer.lock index b56a61f9..9ccb922e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ca9c95fb85f6bf3d9b8a3aa98ca33f6", + "content-hash": "b47c8579efa64349c5e65296e2a44206", "packages": [ { "name": "amphp/amp", @@ -16830,6 +16830,54 @@ ], "time": "2025-05-14T06:15:44+00:00" }, + { + "name": "tiendanube/gtinvalidation", + "version": "v1.0", + "source": { + "type": "git", + "url": "https://github.com/TiendaNube/GtinValidator.git", + "reference": "7ff5794b6293eb748bf1efcddf4e20a657c31855" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/TiendaNube/GtinValidator/zipball/7ff5794b6293eb748bf1efcddf4e20a657c31855", + "reference": "7ff5794b6293eb748bf1efcddf4e20a657c31855", + "shasum": "" + }, + "require": { + "php": ">=5.4" + }, + "require-dev": { + "phpunit/phpunit": "4.6.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "GtinValidation\\": "src/GtinValidation" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Blakemore", + "homepage": "https://github.com/ryanblak", + "role": "developer" + } + ], + "description": "Validates GTIN product codes.", + "keywords": [ + "gtin", + "product codes" + ], + "support": { + "issues": "https://github.com/TiendaNube/GtinValidator/issues", + "source": "https://github.com/TiendaNube/GtinValidator/tree/master" + }, + "time": "2018-07-25T22:31:29+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index 38130f0d..065469b5 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits; use App\Entity\Parts\InfoProviderReference; use App\Entity\Parts\PartCustomState; +use App\Validator\Constraints\ValidGTIN; use Doctrine\DBAL\Types\Types; use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; @@ -89,7 +90,7 @@ trait AdvancedPropertyTrait */ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] #[ORM\Column(type: Types::STRING, nullable: true)] - #[Length(max: 14)] + #[ValidGTIN] protected ?string $gtin = null; /** diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index b8276589..902aff40 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -216,7 +216,13 @@ class PartBaseType extends AbstractType 'disable_not_selectable' => true, 'label' => 'part.edit.partCustomState', ]) - ->add('ipn', TextType::class, $ipnOptions); + ->add('ipn', TextType::class, $ipnOptions) + ->add('gtin', TextType::class, [ + 'required' => false, + 'empty_data' => null, + 'label' => 'part.gtin', + ]) + ; //Comment section $builder->add('comment', RichTextEditorType::class, [ diff --git a/src/Validator/Constraints/ValidGTIN.php b/src/Validator/Constraints/ValidGTIN.php new file mode 100644 index 00000000..20e81f1d --- /dev/null +++ b/src/Validator/Constraints/ValidGTIN.php @@ -0,0 +1,35 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * A constraint to ensure that a GTIN is valid. + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class ValidGTIN extends Constraint +{ + +} diff --git a/src/Validator/Constraints/ValidGTINValidator.php b/src/Validator/Constraints/ValidGTINValidator.php new file mode 100644 index 00000000..57eb23e6 --- /dev/null +++ b/src/Validator/Constraints/ValidGTINValidator.php @@ -0,0 +1,54 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Validator\Constraints; + +use GtinValidation\GtinValidator; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +class ValidGTINValidator extends ConstraintValidator +{ + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof ValidGTIN) { + throw new UnexpectedTypeException($constraint, ValidGTIN::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_string($value)) { + throw new UnexpectedTypeException($value, 'string'); + } + + $gtinValidator = new GtinValidator($value); + if (!$gtinValidator->isValid()) { + $this->context->buildViolation('validator.invalid_gtin') + ->addViolation(); + } + } +} diff --git a/templates/parts/edit/_advanced.html.twig b/templates/parts/edit/_advanced.html.twig index b0f1ff86..30479d11 100644 --- a/templates/parts/edit/_advanced.html.twig +++ b/templates/parts/edit/_advanced.html.twig @@ -13,4 +13,5 @@ {{ form_row(form.ipn) }} {{ form_row(form.partUnit) }} -{{ form_row(form.partCustomState) }} \ No newline at end of file +{{ form_row(form.partCustomState) }} +{{ form_row(form.gtin) }} diff --git a/tests/Validator/Constraints/ValidGTINValidatorTest.php b/tests/Validator/Constraints/ValidGTINValidatorTest.php new file mode 100644 index 00000000..f35b053a --- /dev/null +++ b/tests/Validator/Constraints/ValidGTINValidatorTest.php @@ -0,0 +1,72 @@ +. + */ + +namespace App\Tests\Validator\Constraints; + +use App\Validator\Constraints\ValidGTINValidator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class ValidGTINValidatorTest extends ConstraintValidatorTestCase +{ + + public function testAllowNull(): void + { + $this->validator->validate(null, new \App\Validator\Constraints\ValidGTIN()); + $this->assertNoViolation(); + } + + public function testValidGTIN8(): void + { + $this->validator->validate('12345670', new \App\Validator\Constraints\ValidGTIN()); + $this->assertNoViolation(); + } + + public function testValidGTIN12(): void + { + $this->validator->validate('123456789012', new \App\Validator\Constraints\ValidGTIN()); + $this->assertNoViolation(); + } + + public function testValidGTIN13(): void + { + $this->validator->validate('1234567890128', new \App\Validator\Constraints\ValidGTIN()); + $this->assertNoViolation(); + } + + public function testValidGTIN14(): void + { + $this->validator->validate('12345678901231', new \App\Validator\Constraints\ValidGTIN()); + $this->assertNoViolation(); + } + + public function testInvalidGTIN(): void + { + $this->validator->validate('1234567890123', new \App\Validator\Constraints\ValidGTIN()); + $this->buildViolation('validator.invalid_gtin') + ->assertRaised(); + } + + protected function createValidator(): ConstraintValidatorInterface + { + return new ValidGTINValidator(); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 5692ed25..66053133 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12401,5 +12401,11 @@ Buerklin-API Authentication server: Update to + + + part.gtin + GTIN / EAN + + - \ No newline at end of file + diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index e2e70d03..ed824f0b 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -247,5 +247,11 @@ There is already a translation defined for this type and language! + + + validator.invalid_gtin + This is not an valid GTIN / EAN! + + - \ No newline at end of file + From fd76ca12fc67aeb76d46b867c64d69e75896e563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 15:32:35 +0100 Subject: [PATCH 03/21] Allow to import GTIN from info providers --- src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php | 2 ++ src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php | 4 ++++ src/Services/InfoProviderSystem/DTOtoEntityConverter.php | 2 ++ .../InfoProviderSystem/Providers/ConradProvider.php | 4 ++++ .../InfoProviderSystem/Providers/ProviderCapabilities.php | 6 ++++++ templates/info_providers/search/part_search.html.twig | 8 +++++++- templates/parts/info/_sidebar.html.twig | 8 ++++++++ translations/messages.en.xlf | 6 ++++++ 8 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 41d50510..9700ae57 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -42,6 +42,7 @@ class PartDetailDTO extends SearchResultDTO ?ManufacturingStatus $manufacturing_status = null, ?string $provider_url = null, ?string $footprint = null, + ?string $gtin = null, public readonly ?string $notes = null, /** @var FileDTO[]|null */ public readonly ?array $datasheets = null, @@ -68,6 +69,7 @@ class PartDetailDTO extends SearchResultDTO manufacturing_status: $manufacturing_status, provider_url: $provider_url, footprint: $footprint, + gtin: $gtin ); } } diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index a70b2486..085ae17e 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -59,6 +59,8 @@ class SearchResultDTO public readonly ?string $provider_url = null, /** @var string|null A footprint representation of the providers page */ public readonly ?string $footprint = null, + /** @var string|null The GTIN / EAN of the part */ + public readonly ?string $gtin = null, ) { if ($preview_image_url !== null) { @@ -90,6 +92,7 @@ class SearchResultDTO 'manufacturing_status' => $this->manufacturing_status?->value, 'provider_url' => $this->provider_url, 'footprint' => $this->footprint, + 'gtin' => $this->gtin, ]; } @@ -112,6 +115,7 @@ class SearchResultDTO manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null, provider_url: $data['provider_url'] ?? null, footprint: $data['footprint'] ?? null, + gtin: $data['gtin'] ?? null, ); } } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index a655a0df..d3a7c52c 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -175,6 +175,8 @@ final class DTOtoEntityConverter $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); $entity->setManufacturerProductURL($dto->manufacturer_product_url ?? ''); + $entity->setGtin($dto->gtin); + //Set the provider reference on the part $entity->setProviderReference(InfoProviderReference::fromPartDTO($dto)); diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 32434dee..3086b7d8 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -120,6 +120,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr preview_image_url: $result['image'] ?? null, provider_url: $this->getProductUrl($result['productId']), footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []), + gtin: $result['ean'] ?? null, ); } @@ -302,6 +303,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null, provider_url: $this->getProductUrl($data['shortProductNumber']), footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []), + gtin: $data['productFullInformation']['eanCode'] ?? null, notes: $data['productFullInformation']['description'] ?? null, datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []), parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []), @@ -316,6 +318,8 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::GTIN, ]; } diff --git a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php index bced19de..21fba53b 100644 --- a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php +++ b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php @@ -43,6 +43,9 @@ enum ProviderCapabilities /** Information about the footprint of a part */ case FOOTPRINT; + /** Provider can provide GTIN for a part */ + case GTIN; + /** * Get the order index for displaying capabilities in a stable order. * @return int @@ -55,6 +58,7 @@ enum ProviderCapabilities self::DATASHEET => 3, self::PRICE => 4, self::FOOTPRINT => 5, + self::GTIN => 6, }; } @@ -66,6 +70,7 @@ enum ProviderCapabilities self::PICTURE => 'picture', self::DATASHEET => 'datasheet', self::PRICE => 'price', + self::GTIN => 'gtin', }; } @@ -77,6 +82,7 @@ enum ProviderCapabilities self::PICTURE => 'fa-image', self::DATASHEET => 'fa-file-alt', self::PRICE => 'fa-money-bill-wave', + self::GTIN => 'fa-barcode', }; } } diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index 3d741c34..a5602618 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -94,7 +94,13 @@ {{ dto.footprint }} {% endif %} - {{ helper.m_status_to_badge(dto.manufacturing_status) }} + + {{ helper.m_status_to_badge(dto.manufacturing_status) }} + {% if dto.gtin %} +
+ {{ dto.gtin }} + {% endif %} + {% if dto.provider_url %} diff --git a/templates/parts/info/_sidebar.html.twig b/templates/parts/info/_sidebar.html.twig index 0c353d8f..12060241 100644 --- a/templates/parts/info/_sidebar.html.twig +++ b/templates/parts/info/_sidebar.html.twig @@ -27,6 +27,14 @@ {% endif %} +{% if part.gtin %} +
+
+ {{ part.gtin }} +
+
+{% endif %} + {# Needs Review tag #} {% if part.needsReview %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 66053133..f9689089 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12407,5 +12407,11 @@ Buerklin-API Authentication server: GTIN / EAN + + + info_providers.capabilities.gtin + GTIN / EAN + + From 1130f71075926084222b8683e9eabf1bc4cdbc44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 15:43:50 +0100 Subject: [PATCH 04/21] Added ability to get GTINs for reichelt and Generic WebURL --- .../Providers/GenericWebProvider.php | 6 ++++-- .../Providers/ReicheltProvider.php | 17 +++++++++++++++-- .../DTOtoEntityConverterTest.php | 15 +++++++++------ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 7fbf5a58..ada72ea2 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -227,10 +227,11 @@ class GenericWebProvider implements InfoProviderInterface mpn: $product->mpn?->toString(), preview_image_url: $image, provider_url: $url, + gtin: $product->gtin14?->toString() ?? $product->gtin13?->toString() ?? $product->gtin12?->toString() ?? $product->gtin8?->toString(), notes: $notes, parameters: $parameters, vendor_infos: $vendor_infos, - mass: $mass + mass: $mass, ); } @@ -429,7 +430,8 @@ class GenericWebProvider implements InfoProviderInterface return [ ProviderCapabilities::BASIC, ProviderCapabilities::PICTURE, - ProviderCapabilities::PRICE + ProviderCapabilities::PRICE, + ProviderCapabilities::GTIN, ]; } } diff --git a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index 5c8efbf1..0adf9ab6 100644 --- a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php @@ -84,6 +84,8 @@ class ReicheltProvider implements InfoProviderInterface $name = $element->filter('meta[itemprop="name"]')->attr('content'); $sku = $element->filter('meta[itemprop="sku"]')->attr('content'); + + //Try to extract a picture URL: $pictureURL = $element->filter("div.al_artlogo img")->attr('src'); @@ -95,7 +97,8 @@ class ReicheltProvider implements InfoProviderInterface category: null, manufacturer: $sku, preview_image_url: $pictureURL, - provider_url: $element->filter('a.al_artinfo_link')->attr('href') + provider_url: $element->filter('a.al_artinfo_link')->attr('href'), + ); }); @@ -146,6 +149,14 @@ class ReicheltProvider implements InfoProviderInterface $priceString = $dom->filter('meta[itemprop="price"]')->attr('content'); $currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR'); + foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8'] as $gtinType) { + if ($dom->filter("[itemprop=\"$gtinType\"]")->count() > 0) { + $gtin = $dom->filter("[itemprop=\"$gtinType\"]")->innerText(); + break; + } + } + + //Create purchase info $purchaseInfo = new PurchaseInfoDTO( distributor_name: self::DISTRIBUTOR_NAME, @@ -167,10 +178,11 @@ class ReicheltProvider implements InfoProviderInterface mpn: $this->parseMPN($dom), preview_image_url: $json[0]['article_picture'], provider_url: $productPage, + gtin: $gtin, notes: $notes, datasheets: $datasheets, parameters: $this->parseParameters($dom), - vendor_infos: [$purchaseInfo] + vendor_infos: [$purchaseInfo], ); } @@ -273,6 +285,7 @@ class ReicheltProvider implements InfoProviderInterface ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::GTIN, ]; } } diff --git a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php index 6c933472..78e79167 100644 --- a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php +++ b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php @@ -159,12 +159,13 @@ class DTOtoEntityConverterTest extends WebTestCase $shopping_infos = [new PurchaseInfoDTO('TestDistributor', 'TestOrderNumber', [new PriceDTO(1, "10.0", 'EUR')])]; $dto = new PartDetailDTO( - provider_key: 'test_provider', provider_id: 'test_id', provider_url: 'https://invalid.invalid/test_id', - name: 'TestPart', description: 'TestDescription', category: 'TestCategory', - manufacturer: 'TestManufacturer', mpn: 'TestMPN', manufacturing_status: ManufacturingStatus::EOL, - preview_image_url: 'https://invalid.invalid/image.png', - footprint: 'DIP8', notes: 'TestNotes', mass: 10.4, - parameters: $parameters, datasheets: $datasheets, vendor_infos: $shopping_infos, images: $images + provider_key: 'test_provider', provider_id: 'test_id', name: 'TestPart', + description: 'TestDescription', category: 'TestCategory', manufacturer: 'TestManufacturer', + mpn: 'TestMPN', preview_image_url: 'https://invalid.invalid/image.png', + manufacturing_status: ManufacturingStatus::EOL, + provider_url: 'https://invalid.invalid/test_id', + footprint: 'DIP8', gtin: "1234567890123", notes: 'TestNotes', datasheets: $datasheets, + images: $images, parameters: $parameters, vendor_infos: $shopping_infos, mass: 10.4 ); $entity = $this->service->convertPart($dto); @@ -180,6 +181,8 @@ class DTOtoEntityConverterTest extends WebTestCase $this->assertEquals($dto->mass, $entity->getMass()); $this->assertEquals($dto->footprint, $entity->getFootprint()); + $this->assertEquals($dto->gtin, $entity->getGtin()); + //We just check that the lenghts of parameters, datasheets, images and shopping infos are the same //The actual content is tested in the corresponding tests $this->assertCount(count($parameters), $entity->getParameters()); From a962e5e019568447cdac17e8d4a13ada6906000a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 15:51:39 +0100 Subject: [PATCH 05/21] Allow to order and filter by GTIN in part tables --- src/DataTables/Filters/PartFilter.php | 2 ++ src/DataTables/PartsDataTable.php | 4 ++++ src/Form/Filters/PartFilterType.php | 4 ++++ src/Settings/BehaviorSettings/PartTableColumns.php | 1 + templates/parts/lists/_filter.html.twig | 3 ++- translations/messages.en.xlf | 6 ++++++ 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index cf185dfd..a08293ca 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -66,6 +66,7 @@ class PartFilter implements FilterInterface public readonly BooleanConstraint $favorite; public readonly BooleanConstraint $needsReview; public readonly NumberConstraint $mass; + public readonly TextConstraint $gtin; public readonly DateTimeConstraint $lastModified; public readonly DateTimeConstraint $addedDate; public readonly EntityConstraint $category; @@ -132,6 +133,7 @@ class PartFilter implements FilterInterface $this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit'); $this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState'); $this->mass = new NumberConstraint('part.mass'); + $this->gtin = new TextConstraint('part.gtin'); $this->dbId = new IntConstraint('part.id'); $this->ipn = new TextConstraint('part.ipn'); $this->addedDate = new DateTimeConstraint('part.addedDate'); diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 0baee630..fbc5211d 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -218,6 +218,10 @@ final class PartsDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('part.table.mass'), 'unit' => 'g' ]) + ->add('gtin', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.gtin'), + 'orderField' => 'NATSORT(part.gtin)' + ]) ->add('tags', TagsColumn::class, [ 'label' => $this->translator->trans('part.table.tags'), ]) diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index e101c635..25fe70b2 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -135,6 +135,10 @@ class PartFilterType extends AbstractType 'min' => 0, ]); + $builder->add('gtin', TextConstraintType::class, [ + 'label' => 'part.gtin', + ]); + $builder->add('measurementUnit', StructuralEntityConstraintType::class, [ 'label' => 'part.edit.partUnit', 'entity_class' => MeasurementUnit::class diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php index c025c952..2ea66525 100644 --- a/src/Settings/BehaviorSettings/PartTableColumns.php +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -48,6 +48,7 @@ enum PartTableColumns : string implements TranslatableInterface case MPN = "manufacturer_product_number"; case CUSTOM_PART_STATE = 'partCustomState'; case MASS = "mass"; + case GTIN = "gtin"; case TAGS = "tags"; case ATTACHMENTS = "attachments"; case EDIT = "edit"; diff --git a/templates/parts/lists/_filter.html.twig b/templates/parts/lists/_filter.html.twig index 2fb5bff2..3130f379 100644 --- a/templates/parts/lists/_filter.html.twig +++ b/templates/parts/lists/_filter.html.twig @@ -65,6 +65,7 @@ {{ form_row(filterForm.mass) }} {{ form_row(filterForm.dbId) }} {{ form_row(filterForm.ipn) }} + {{ form_row(filterForm.gtin) }} {{ form_row(filterForm.lastModified) }} {{ form_row(filterForm.addedDate) }}
@@ -163,4 +164,4 @@ {{ form_end(filterForm) }} - \ No newline at end of file + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f9689089..8953982d 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12413,5 +12413,11 @@ Buerklin-API Authentication server: GTIN / EAN + + + part.table.gtin + GTIN + + From 4de6dbba278489c296ad9344eb775bc35665a1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 15:53:45 +0100 Subject: [PATCH 06/21] Show GTIN in part extended info tab --- templates/parts/info/_extended_infos.html.twig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/templates/parts/info/_extended_infos.html.twig b/templates/parts/info/_extended_infos.html.twig index 4ed60a09..9cb4e4e5 100644 --- a/templates/parts/info/_extended_infos.html.twig +++ b/templates/parts/info/_extended_infos.html.twig @@ -42,6 +42,11 @@ {{ part.ipn ?? 'part.ipn.not_defined'|trans }} + + {% trans %}part.gtin{% endtrans %} + {{ part.gtin ?? '' }} + + {# Favorite status #} {% trans %}part.isFavorite{% endtrans %} {{ helper.boolean_badge(part.favorite) }} @@ -106,4 +111,4 @@ - \ No newline at end of file + From 35e844dd7bfbe079d1e093c98f11cf6332d15ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 16:06:01 +0100 Subject: [PATCH 07/21] Allow to scan gtin barcodes and find parts via it --- src/Form/LabelSystem/ScanDialogType.php | 3 +- .../BarcodeScanner/BarcodeRedirector.php | 14 ++++ .../BarcodeScanner/BarcodeScanHelper.php | 13 ++++ .../BarcodeScanner/BarcodeSourceType.php | 7 +- .../BarcodeScanner/GTINBarcodeScanResult.php | 64 +++++++++++++++++++ translations/messages.en.xlf | 6 ++ 6 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 13ff8e6f..9199c31d 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal', BarcodeSourceType::IPN => 'scan_dialog.mode.ipn', BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', - BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp' + BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', + BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', }, ]); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php index d5ddc1de..1a3c29c2 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php @@ -77,6 +77,10 @@ final class BarcodeRedirector return $this->getURLVendorBarcode($barcodeScan); } + if ($barcodeScan instanceof GTINBarcodeScanResult) { + return $this->getURLGTINBarcode($barcodeScan); + } + throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan)); } @@ -111,6 +115,16 @@ final class BarcodeRedirector return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); } + private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string + { + $part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); + if (!$part instanceof Part) { + throw new EntityNotFoundException(); + } + + return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); + } + /** * Gets a part from a scan of a Vendor Barcode by filtering for parts * with the same Info Provider Id or, if that fails, by looking for parts with a diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index e5930b36..520c9f3b 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -92,6 +92,9 @@ final class BarcodeScanHelper if ($type === BarcodeSourceType::EIGP114) { return $this->parseEIGP114Barcode($input); } + if ($type === BarcodeSourceType::GTIN) { + return $this->parseGTINBarcode($input); + } //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -117,9 +120,19 @@ final class BarcodeScanHelper return $result; } + //If the result is a valid GTIN barcode, we can parse it directly + if (GTINBarcodeScanResult::isValidGTIN($input)) { + return $this->parseGTINBarcode($input); + } + throw new InvalidArgumentException('Unknown barcode'); } + private function parseGTINBarcode(string $input): GTINBarcodeScanResult + { + return new GTINBarcodeScanResult($input); + } + private function parseEIGP114Barcode(string $input): EIGP114BarcodeScanResult { return EIGP114BarcodeScanResult::parseFormat06Code($input); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index 40f707de..43643d12 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -42,4 +42,9 @@ enum BarcodeSourceType * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ case EIGP114; -} \ No newline at end of file + + /** + * GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part. + */ + case GTIN; +} diff --git a/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php new file mode 100644 index 00000000..389bfdcc --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php @@ -0,0 +1,64 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\LabelSystem\BarcodeScanner; + +use GtinValidation\GtinValidator; + +readonly class GTINBarcodeScanResult implements BarcodeScanResultInterface +{ + + private GtinValidator $validator; + + public function __construct( + public string $gtin, + ) { + $this->validator = new GtinValidator($this->gtin); + } + + public function getDecodedForInfoMode(): array + { + $obj = $this->validator->getGtinObject(); + return [ + 'GTIN' => $this->gtin, + 'GTIN type' => $obj->getType(), + 'Valid' => $this->validator->isValid() ? 'Yes' : 'No', + 'Reference Number' => $obj->getReferenceNumber(), + 'Check Digit' => $obj->getCheckDigit(), + ]; + } + + /** + * Checks if the given input is a valid GTIN. This is used to determine whether a scanned barcode should be interpreted as a GTIN or not. + * @param string $input + * @return bool + */ + public static function isValidGTIN(string $input): bool + { + try { + return (new GtinValidator($input))->isValid(); + } catch (\Exception $e) { + return false; + } + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 8953982d..b2a577b0 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12419,5 +12419,11 @@ Buerklin-API Authentication server: GTIN + + + scan_dialog.mode.gtin + GTIN / EAN + + From 2c56ec746c6a5878052dee63a8f6d83428c1b1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 16:07:11 +0100 Subject: [PATCH 08/21] Improved translation --- translations/messages.en.xlf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b2a577b0..a1ebc9b9 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12422,7 +12422,7 @@ Buerklin-API Authentication server: scan_dialog.mode.gtin - GTIN / EAN + GTIN / EAN barcode From a4c2b8f885b1358e971897af9ed57ca16448f6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 19:30:06 +0100 Subject: [PATCH 09/21] Added the option to only show attachment types for certain element classes --- src/Entity/Attachments/Attachment.php | 2 +- src/Entity/Attachments/AttachmentType.php | 84 ++++++++++++++++++- .../AdminPages/AttachmentTypeAdminForm.php | 27 +++++- src/Form/AttachmentFormType.php | 5 +- src/Form/Type/AttachmentTypeType.php | 56 +++++++++++++ .../admin/attachment_type_admin.html.twig | 1 + .../Entity/Attachments/AttachmentTypeTest.php | 49 +++++++++++ translations/messages.en.xlf | 12 +++ 8 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 src/Form/Type/AttachmentTypeType.php diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index ac625e92..d4b15ac7 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement * @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses. * @phpstan-var class-string */ - protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class; + public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class; /** * @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it. diff --git a/src/Entity/Attachments/AttachmentType.php b/src/Entity/Attachments/AttachmentType.php index 273e800a..375defa0 100644 --- a/src/Entity/Attachments/AttachmentType.php +++ b/src/Entity/Attachments/AttachmentType.php @@ -135,11 +135,16 @@ class AttachmentType extends AbstractStructuralDBElement protected Collection $attachments_with_type; /** - * @var array|null A list of allowed targets where this attachment type can be assigned to. + * @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names */ #[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)] protected ?array $allowed_targets = null; + /** + * @var class-string[]|null + */ + protected ?array $allowed_targets_parsed_cache = null; + #[Groups(['attachment_type:read'])] protected ?\DateTimeImmutable $addedDate = null; #[Groups(['attachment_type:read'])] @@ -190,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement return $this; } + + /** + * Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions. + * @return class-string[]|null + */ + public function getAllowedTargets(): ?array + { + //Use cached value if available + if ($this->allowed_targets_parsed_cache !== null) { + return $this->allowed_targets_parsed_cache; + } + + if (empty($this->allowed_targets)) { + return null; + } + + $tmp = []; + foreach ($this->allowed_targets as $target) { + if (Attachment::ORM_DISCRIMINATOR_MAP[$target]) { + $tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target]; + } + //Otherwise ignore the entry, as it is invalid + } + + //Cache the parsed value + $this->allowed_targets_parsed_cache = $tmp; + return $tmp; + } + + /** + * Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions. + * @param class-string[]|null $allowed_targets + * @return $this + */ + public function setAllowedTargets(?array $allowed_targets): self + { + if ($allowed_targets === null) { + $this->allowed_targets = null; + } else { + $tmp = []; + foreach ($allowed_targets as $target) { + $discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true); + if ($discriminator !== false) { + $tmp[] = $discriminator; + } else { + throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass."); + } + } + $this->allowed_targets = $tmp; + } + + //Reset the cache + $this->allowed_targets_parsed_cache = null; + return $this; + } + + /** + * Checks if this attachment type is allowed for the given attachment target. + * @param Attachment|string $attachment + * @return bool + */ + public function isAllowedForTarget(Attachment|string $attachment): bool + { + //If no restrictions are set, allow all targets + if ($this->getAllowedTargets() === null) { + return true; + } + + //Iterate over all allowed targets and check if the attachment is an instance of any of them + foreach ($this->getAllowedTargets() as $allowed_target) { + if (is_a($attachment, $allowed_target, true)) { + return true; + } + } + + return false; + } } diff --git a/src/Form/AdminPages/AttachmentTypeAdminForm.php b/src/Form/AdminPages/AttachmentTypeAdminForm.php index d777d4d4..cf410a43 100644 --- a/src/Form/AdminPages/AttachmentTypeAdminForm.php +++ b/src/Form/AdminPages/AttachmentTypeAdminForm.php @@ -22,17 +22,23 @@ declare(strict_types=1); namespace App\Form\AdminPages; +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Attachments\ProjectAttachment; +use App\Services\ElementTypeNameGenerator; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Services\Attachments\FileTypeFilterTools; use App\Services\LogSystem\EventCommentNeededHelper; use Symfony\Component\Form\CallbackTransformer; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Translation\StaticMessage; class AttachmentTypeAdminForm extends BaseEntityAdminForm { - public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper) + public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator) { parent::__construct($security, $eventCommentNeededHelper); } @@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm { $is_new = null === $entity->getID(); + + $choiceLabel = function (string $class) { + if (!is_a($class, Attachment::class, true)) { + return $class; + } + return new StaticMessage($this->elementTypeNameGenerator->typeLabel($class::ALLOWED_ELEMENT_CLASS)); + }; + + + $builder->add('allowed_targets', ChoiceType::class, [ + 'required' => false, + 'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP), + 'choice_label' => $choiceLabel, + 'preferred_choices' => [PartAttachment::class, ProjectAttachment::class], + 'label' => 'attachment_type.edit.allowed_targets', + 'help' => 'attachment_type.edit.allowed_targets.help', + 'multiple' => true, + ]); + $builder->add('filetype_filter', TextType::class, [ 'required' => false, 'label' => 'attachment_type.edit.filetype_filter', diff --git a/src/Form/AttachmentFormType.php b/src/Form/AttachmentFormType.php index eb484a58..5cbde178 100644 --- a/src/Form/AttachmentFormType.php +++ b/src/Form/AttachmentFormType.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form; +use App\Form\Type\AttachmentTypeType; use App\Settings\SystemSettings\AttachmentsSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\Attachment; @@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType 'required' => false, 'empty_data' => '', ]) - ->add('attachment_type', StructuralEntityType::class, [ + ->add('attachment_type', AttachmentTypeType::class, [ 'label' => 'attachment.edit.attachment_type', - 'class' => AttachmentType::class, 'disable_not_selectable' => true, + 'attachment_filter_class' => $options['data_class'] ?? null, 'allow_add' => $this->security->isGranted('@attachment_types.create'), ]); diff --git a/src/Form/Type/AttachmentTypeType.php b/src/Form/Type/AttachmentTypeType.php new file mode 100644 index 00000000..099ed282 --- /dev/null +++ b/src/Form/Type/AttachmentTypeType.php @@ -0,0 +1,56 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Type; + +use App\Entity\Attachments\AttachmentType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class. + */ +class AttachmentTypeType extends AbstractType +{ + public function getParent(): ?string + { + return StructuralEntityType::class; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null); + + $resolver->setDefault('class', AttachmentType::class); + + $resolver->setDefault('choice_filter', function (Options $options) { + if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) { + return static function (?AttachmentType $choice) use ($options) { + return $choice?->isAllowedForTarget($options['attachment_filter_class']); + }; + } + return null; + }); + } +} diff --git a/templates/admin/attachment_type_admin.html.twig b/templates/admin/attachment_type_admin.html.twig index 87a053af..9aeba934 100644 --- a/templates/admin/attachment_type_admin.html.twig +++ b/templates/admin/attachment_type_admin.html.twig @@ -6,6 +6,7 @@ {% block additional_controls %} {{ form_row(form.filetype_filter) }} + {{ form_row(form.allowed_targets) }} {{ form_row(form.alternative_names) }} {% endblock %} diff --git a/tests/Entity/Attachments/AttachmentTypeTest.php b/tests/Entity/Attachments/AttachmentTypeTest.php index f9f781d8..ea80db11 100644 --- a/tests/Entity/Attachments/AttachmentTypeTest.php +++ b/tests/Entity/Attachments/AttachmentTypeTest.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace App\Tests\Entity\Attachments; use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Attachments\UserAttachment; use Doctrine\Common\Collections\Collection; use PHPUnit\Framework\TestCase; @@ -34,4 +36,51 @@ class AttachmentTypeTest extends TestCase $this->assertInstanceOf(Collection::class, $attachment_type->getAttachmentsForType()); $this->assertEmpty($attachment_type->getFiletypeFilter()); } + + public function testSetAllowedTargets(): void + { + $attachmentType = new AttachmentType(); + + + $this->expectException(\InvalidArgumentException::class); + $attachmentType->setAllowedTargets(['target1', 'target2']); + } + + public function testGetSetAllowedTargets(): void + { + $attachmentType = new AttachmentType(); + + $attachmentType->setAllowedTargets([PartAttachment::class, UserAttachment::class]); + $this->assertSame([PartAttachment::class, UserAttachment::class], $attachmentType->getAllowedTargets()); + //Caching should also work + $this->assertSame([PartAttachment::class, UserAttachment::class], $attachmentType->getAllowedTargets()); + + //Setting null should reset the allowed targets + $attachmentType->setAllowedTargets(null); + $this->assertNull($attachmentType->getAllowedTargets()); + } + + public function testIsAllowedForTarget(): void + { + $attachmentType = new AttachmentType(); + + //By default, all targets should be allowed + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); + + //Set specific allowed targets + $attachmentType->setAllowedTargets([PartAttachment::class]); + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertFalse($attachmentType->isAllowedForTarget(UserAttachment::class)); + + //Set both targets + $attachmentType->setAllowedTargets([PartAttachment::class, UserAttachment::class]); + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); + + //Reset allowed targets + $attachmentType->setAllowedTargets(null); + $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); + $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index a1ebc9b9..f47c4e9e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12425,5 +12425,17 @@ Buerklin-API Authentication server: GTIN / EAN barcode + + + attachment_type.edit.allowed_targets + Use only for + + + + + attachment_type.edit.allowed_targets.help + Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes. + + From 90c82aab2e4d4e9bed3c184cf772783d93d85af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 19:31:45 +0100 Subject: [PATCH 10/21] Only show the created avatar attachment type for user attachments --- src/Form/AdminPages/AttachmentTypeAdminForm.php | 2 +- src/Services/UserSystem/UserAvatarHelper.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Form/AdminPages/AttachmentTypeAdminForm.php b/src/Form/AdminPages/AttachmentTypeAdminForm.php index cf410a43..7f9e7646 100644 --- a/src/Form/AdminPages/AttachmentTypeAdminForm.php +++ b/src/Form/AdminPages/AttachmentTypeAdminForm.php @@ -52,7 +52,7 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm if (!is_a($class, Attachment::class, true)) { return $class; } - return new StaticMessage($this->elementTypeNameGenerator->typeLabel($class::ALLOWED_ELEMENT_CLASS)); + return new StaticMessage($this->elementTypeNameGenerator->typeLabelPlural($class::ALLOWED_ELEMENT_CLASS)); }; diff --git a/src/Services/UserSystem/UserAvatarHelper.php b/src/Services/UserSystem/UserAvatarHelper.php index 9dbe9c12..a1a69cb9 100644 --- a/src/Services/UserSystem/UserAvatarHelper.php +++ b/src/Services/UserSystem/UserAvatarHelper.php @@ -154,6 +154,7 @@ class UserAvatarHelper $attachment_type = new AttachmentType(); $attachment_type->setName('Avatars'); $attachment_type->setFiletypeFilter('image/*'); + $attachment_type->setAllowedTargets([UserAttachment::class]); $this->entityManager->persist($attachment_type); } From f95e39748e7d138bc22083d29a9efce99b1aa39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 19:37:44 +0100 Subject: [PATCH 11/21] Fixed PHPstan issue --- src/Entity/Attachments/AttachmentType.php | 2 +- src/Services/EntityMergers/Mergers/PartMerger.php | 3 ++- src/Services/InfoProviderSystem/Providers/ReicheltProvider.php | 1 + .../LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php | 2 -- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Entity/Attachments/AttachmentType.php b/src/Entity/Attachments/AttachmentType.php index 375defa0..7a314ffe 100644 --- a/src/Entity/Attachments/AttachmentType.php +++ b/src/Entity/Attachments/AttachmentType.php @@ -213,7 +213,7 @@ class AttachmentType extends AbstractStructuralDBElement $tmp = []; foreach ($this->allowed_targets as $target) { - if (Attachment::ORM_DISCRIMINATOR_MAP[$target]) { + if (isset(Attachment::ORM_DISCRIMINATOR_MAP[$target])) { $tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target]; } //Otherwise ignore the entry, as it is invalid diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php index d1f5c137..8397257e 100644 --- a/src/Services/EntityMergers/Mergers/PartMerger.php +++ b/src/Services/EntityMergers/Mergers/PartMerger.php @@ -59,6 +59,7 @@ class PartMerger implements EntityMergerInterface $this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number'); $this->useOtherValueIfNotEmtpy($target, $other, 'mass'); $this->useOtherValueIfNotEmtpy($target, $other, 'ipn'); + $this->useOtherValueIfNotEmtpy($target, $other, 'gtin'); //Merge relations to other entities $this->useOtherValueIfNotNull($target, $other, 'manufacturer'); @@ -184,4 +185,4 @@ class PartMerger implements EntityMergerInterface } } } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index 0adf9ab6..88bf33cb 100644 --- a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php @@ -149,6 +149,7 @@ class ReicheltProvider implements InfoProviderInterface $priceString = $dom->filter('meta[itemprop="price"]')->attr('content'); $currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR'); + $gtin = null; foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8'] as $gtinType) { if ($dom->filter("[itemprop=\"$gtinType\"]")->count() > 0) { $gtin = $dom->filter("[itemprop=\"$gtinType\"]")->innerText(); diff --git a/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php index 389bfdcc..30aaa223 100644 --- a/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php @@ -43,8 +43,6 @@ readonly class GTINBarcodeScanResult implements BarcodeScanResultInterface 'GTIN' => $this->gtin, 'GTIN type' => $obj->getType(), 'Valid' => $this->validator->isValid() ? 'Yes' : 'No', - 'Reference Number' => $obj->getReferenceNumber(), - 'Check Digit' => $obj->getCheckDigit(), ]; } From 3bff5fa8bd313889c7a52243e86e6fc379aeae11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 21:54:34 +0100 Subject: [PATCH 12/21] Allow to set if prices contain VAT or not in orderdetail --- .../elements/collection_type_controller.js | 12 ++- assets/js/tristate_checkboxes.js | 3 +- src/Entity/PriceInformations/Orderdetail.php | 45 ++++++++++++ src/Entity/PriceInformations/Pricedetail.php | 32 ++++---- src/Form/Part/OrderdetailType.php | 6 ++ .../parts/edit/edit_form_styles.html.twig | 3 +- tests/Entity/PriceSystem/OrderdetailTest.php | 73 +++++++++++++++++++ translations/messages.en.xlf | 6 ++ 8 files changed, 159 insertions(+), 21 deletions(-) diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 14b683e0..67022ef2 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -74,16 +74,24 @@ export default class extends Controller { const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID())); + let ret = null; + //Insert new html after the last child element //If the table has a tbody, insert it there //Afterwards return the newly created row if(targetTable.tBodies[0]) { targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr); - return targetTable.tBodies[0].lastElementChild; + ret = targetTable.tBodies[0].lastElementChild; } else { //Otherwise just insert it targetTable.insertAdjacentHTML('beforeend', newElementStr); - return targetTable.lastElementChild; + ret = targetTable.lastElementChild; } + + //Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it + targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true})); + + return ret; + } /** diff --git a/assets/js/tristate_checkboxes.js b/assets/js/tristate_checkboxes.js index 4cf4fc1e..467099ab 100644 --- a/assets/js/tristate_checkboxes.js +++ b/assets/js/tristate_checkboxes.js @@ -56,7 +56,8 @@ class TristateHelper { document.addEventListener("turbo:load", listener); document.addEventListener("turbo:render", listener); + document.addEventListener("collection:elementAdded", listener); } } -export default new TristateHelper(); \ No newline at end of file +export default new TristateHelper(); diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 8ed76a46..9a9a2823 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\Length; @@ -388,6 +389,50 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N return $this; } + /** + * Checks if the prices of this orderdetail include VAT. This is determined by checking the pricedetails of this + * orderdetail. If there are no pricedetails or if the pricedetails have conflicting values, null is returned. + * @return bool|null + */ + #[Groups(['orderdetail:read'])] + #[SerializedName('prices_include_vat')] + public function getPricesIncludesVAT(): ?bool + { + $value = null; + //We determine that via the pricedetails + foreach ($this->getPricedetails() as $pricedetail) { + /** @var Pricedetail $pricedetail */ + if ($pricedetail->getIncludesVat() === null) { + return null; // If any pricedetail doesn't specify this, we can't determine it + } + + if ($value === null) { + $value = $pricedetail->getIncludesVat(); // Set initial value + } elseif ($value !== $pricedetail->getIncludesVat()) { + return null; // If there are conflicting values, we can't determine it + } + } + + return $value; + } + + /** + * Sets whether the prices of this orderdetail include VAT. This is set for all pricedetails of this orderdetail. + * @param bool|null $includesVat + * @return $this + */ + #[Groups(['orderdetail:write'])] + #[SerializedName('prices_include_vat')] + public function setPricesIncludesVAT(?bool $includesVat): self + { + foreach ($this->getPricedetails() as $pricedetail) { + /** @var Pricedetail $pricedetail */ + $pricedetail->setIncludesVat($includesVat); + } + + return $this; + } + public function getName(): string { return $this->getSupplierPartNr(); diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php index b98ee056..7deb64f9 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -124,9 +124,9 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface /** * @var bool|null Whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. */ - #[ORM\Column(type: Types::BOOLEAN, nullable: true)] + #[ORM\Column(name: "include_vat", type: Types::BOOLEAN, nullable: true)] #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])] - protected ?bool $include_vat = null; + protected ?bool $includes_vat = null; public function __construct() { @@ -271,6 +271,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface return $this->currency?->getIsoCode(); } + /** + * Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. + * @return bool|null + */ + public function getIncludesVat(): ?bool + { + return $this->includes_vat; + } + /******************************************************************************** * * Setters @@ -369,24 +378,13 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface } /** - * Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. - * @return bool|null - */ - public function getIncludeVat(): ?bool - { - return $this->include_vat; - } - - /** - * Sets whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. - * @param bool|null $include_vat + * Set whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. + * @param bool|null $includes_vat * @return $this */ - public function setIncludeVat(?bool $include_vat): self + public function setIncludesVat(?bool $includes_vat): self { - $this->include_vat = $include_vat; + $this->includes_vat = $includes_vat; return $this; } - - } diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php index 53240821..ca295c7e 100644 --- a/src/Form/Part/OrderdetailType.php +++ b/src/Form/Part/OrderdetailType.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form\Part; +use App\Form\Type\TriStateCheckboxType; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Supplier; @@ -73,6 +74,11 @@ class OrderdetailType extends AbstractType 'label' => 'orderdetails.edit.obsolete', ]); + $builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [ + 'required' => false, + 'label' => 'orderdetails.edit.prices_includes_vat', + ]); + //Add pricedetails after we know the data, so we can set the default currency $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void { /** @var Orderdetail $orderdetail */ diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index c2a89b6a..aa68f38a 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -32,6 +32,7 @@ {{ form_row(form.supplierpartnr, {'attr': {'class': 'form-control-sm'}}) }} {{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }} {{ form_widget(form.obsolete) }} + {{ form_row(form.pricesIncludesVAT) }}
@@ -226,4 +227,4 @@ {{ form_errors(form) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tests/Entity/PriceSystem/OrderdetailTest.php b/tests/Entity/PriceSystem/OrderdetailTest.php index 497f9ab3..7eae93aa 100644 --- a/tests/Entity/PriceSystem/OrderdetailTest.php +++ b/tests/Entity/PriceSystem/OrderdetailTest.php @@ -61,4 +61,77 @@ class OrderdetailTest extends TestCase $this->assertSame($price5, $orderdetail->findPriceForQty(5.3)); $this->assertSame($price5, $orderdetail->findPriceForQty(10000)); } + + public function testGetPricesIncludesVAT(): void + { + $orderdetail = new Orderdetail(); + + //By default, the pricesIncludesVAT property should be null for empty orderdetails + $this->assertNull($orderdetail->getPricesIncludesVAT()); + + $price0 = (new Pricedetail())->setMinDiscountQuantity(0.23); + $price1 = (new Pricedetail())->setMinDiscountQuantity(1); + $price5 = (new Pricedetail())->setMinDiscountQuantity(5.3); + + $orderdetail->addPricedetail($price0)->addPricedetail($price1)->addPricedetail($price5); + + //With empty pricedetails, the pricesIncludesVAT property should still be null + $this->assertNull($orderdetail->getPricesIncludesVAT()); + + //If all of the pricedetails have the same value for includesVAT, the pricesIncludesVAT property should return this value + $price0->setIncludesVAT(true); + $price1->setIncludesVAT(true); + $price5->setIncludesVAT(true); + $this->assertTrue($orderdetail->getPricesIncludesVAT()); + + $price0->setIncludesVAT(false); + $price1->setIncludesVAT(false); + $price5->setIncludesVAT(false); + $this->assertFalse($orderdetail->getPricesIncludesVAT()); + + //If the pricedetails have different values for includesVAT, the pricesIncludesVAT property should return null + $price0->setIncludesVAT(true); + $price1->setIncludesVAT(false); + $price5->setIncludesVAT(true); + $this->assertNull($orderdetail->getPricesIncludesVAT()); + + //If the pricedetails have different values for includesVAT, the pricesIncludesVAT property should return null, even if one of them is null + $price0->setIncludesVAT(null); + $price1->setIncludesVAT(false); + $price5->setIncludesVAT(false); + $this->assertNull($orderdetail->getPricesIncludesVAT()); + } + + public function testSetPricesIncludesVAT(): void + { + $orderdetail = new Orderdetail(); + $price0 = (new Pricedetail())->setMinDiscountQuantity(0.23); + $price1 = (new Pricedetail())->setMinDiscountQuantity(1); + $price5 = (new Pricedetail())->setMinDiscountQuantity(5.3); + + $orderdetail->addPricedetail($price0)->addPricedetail($price1)->addPricedetail($price5); + + $this->assertNull($orderdetail->getPricesIncludesVAT()); + + $orderdetail->setPricesIncludesVAT(true); + $this->assertTrue($orderdetail->getPricesIncludesVAT()); + //Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails + foreach ($orderdetail->getPricedetails() as $pricedetail) { + $this->assertTrue($pricedetail->getIncludesVAT()); + } + + $orderdetail->setPricesIncludesVAT(false); + $this->assertFalse($orderdetail->getPricesIncludesVAT()); + //Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails + foreach ($orderdetail->getPricedetails() as $pricedetail) { + $this->assertFalse($pricedetail->getIncludesVAT()); + } + + $orderdetail->setPricesIncludesVAT(null); + $this->assertNull($orderdetail->getPricesIncludesVAT()); + //Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails + foreach ($orderdetail->getPricedetails() as $pricedetail) { + $this->assertNull($pricedetail->getIncludesVAT()); + } + } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f47c4e9e..2310286c 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12437,5 +12437,11 @@ Buerklin-API Authentication server: Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes. + + + orderdetails.edit.prices_includes_vat + Prices include VAT + + From 5a47b15c97922385efa28342920888fa539ce64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 21:58:14 +0100 Subject: [PATCH 13/21] Use the information from info provider whether prices includes VAT or not --- assets/controllers/elements/collection_type_controller.js | 2 +- src/Services/InfoProviderSystem/DTOtoEntityConverter.php | 3 ++- tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 67022ef2..048600a9 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -86,7 +86,7 @@ export default class extends Controller { targetTable.insertAdjacentHTML('beforeend', newElementStr); ret = targetTable.lastElementChild; } - + //Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true})); diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index d3a7c52c..1a93b111 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -94,13 +94,14 @@ final class DTOtoEntityConverter $entity->setPrice($dto->getPriceAsBigDecimal()); $entity->setPriceRelatedQuantity($dto->price_related_quantity); - //Currency TODO if ($dto->currency_iso_code !== null) { $entity->setCurrency($this->getCurrency($dto->currency_iso_code)); } else { $entity->setCurrency(null); } + $entity->setIncludesVat($dto->includes_tax); + return $entity; } diff --git a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php index 78e79167..54878bbf 100644 --- a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php +++ b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php @@ -101,6 +101,8 @@ class DTOtoEntityConverterTest extends WebTestCase //For base currencies, the currency field is null $this->assertNull($entity->getCurrency()); + + $this->assertTrue($entity->getIncludesVat()); } public function testConvertPurchaseInfo(): void From 4740b6d19ef6868584f15a1fc8eae1fc0a209721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Feb 2026 22:09:36 +0100 Subject: [PATCH 14/21] Show in part info page whether price is inclusive VAT or not --- templates/helper.twig | 10 +++- templates/parts/info/_order_infos.html.twig | 59 +++++++++++---------- translations/messages.en.xlf | 12 +++++ 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/templates/helper.twig b/templates/helper.twig index 66268a96..9e68d56c 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -192,7 +192,7 @@ {% set preview_attach = part_preview_generator.tablePreviewAttachment(part) %} {% if preview_attach %} Part image + {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}"> {% endif %} {{ part.name }} {% endmacro %} @@ -241,3 +241,11 @@ {{ datetime|format_datetime }} {% endif %} {% endmacro %} + +{% macro vat_text(bool) %} + {% if bool === true %} + ({% trans %}prices.incl_vat{% endtrans %}) + {% elseif bool === false %} + ({% trans %}prices.excl_vat{% endtrans %}) + {% endif %} +{% endmacro %} diff --git a/templates/parts/info/_order_infos.html.twig b/templates/parts/info/_order_infos.html.twig index 68462de5..59b904df 100644 --- a/templates/parts/info/_order_infos.html.twig +++ b/templates/parts/info/_order_infos.html.twig @@ -24,8 +24,8 @@ {% if order.pricedetails is not empty %} - - +
+ @@ -36,32 +36,35 @@ {% endif %} - - - {% for detail in order.pricedetails %} - + + + {% for detail in order.pricedetails %} + {# @var detail App\Entity\PriceInformations\Pricedetail #} + - - - - - {% endfor %} - -
{% trans %}part.order.minamount{% endtrans %} {% trans %}part.order.price{% endtrans %}
- {{ detail.MinDiscountQuantity | format_amount(part.partUnit) }} - - {{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }} - {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %} - {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} - ({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }}) - {% endif %} - - {{ detail.PricePerUnit | format_money(detail.currency) }} - {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %} - {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} - ({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }}) - {% endif %} -
+ + {{ detail.MinDiscountQuantity | format_amount(part.partUnit) }} + + + {{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }} + {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %} + {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} + ({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }}) + {% endif %} + {{- helper.vat_text(detail.includesVAT) -}} + + + {{ detail.PricePerUnit | format_money(detail.currency) }} + {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %} + {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} + ({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }}) + {% endif %} + {{- helper.vat_text(detail.includesVAT) -}} + + + {% endfor %} + + {% endif %} {# Action for order information #} @@ -80,4 +83,4 @@ {% endfor %} -
\ No newline at end of file + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 2310286c..a776eb9d 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12443,5 +12443,17 @@ Buerklin-API Authentication server: Prices include VAT + + + prices.incl_vat + Incl. VAT + + + + + prices.excl_vat + Excl. VAT + + From 586375d9210967467fe3bd8031c9dcb0f8eadaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 16:53:41 +0100 Subject: [PATCH 15/21] Moved VAT include info from pricedetail to orderdetail level That makes implementing the form easier --- migrations/Version20260208131116.php | 27 ++++---- src/Entity/PriceInformations/Orderdetail.php | 39 ++++-------- src/Entity/PriceInformations/Pricedetail.php | 20 +----- .../InfoProviderSystem/DTOs/PriceDTO.php | 4 +- .../DTOs/PurchaseInfoDTO.php | 16 +++++ .../DTOtoEntityConverter.php | 4 +- tests/Entity/PriceSystem/OrderdetailTest.php | 61 +------------------ .../DTOs/PurchaseInfoDTOTest.php | 37 +++++++++++ .../DTOtoEntityConverterTest.php | 5 +- 9 files changed, 88 insertions(+), 125 deletions(-) diff --git a/migrations/Version20260208131116.php b/migrations/Version20260208131116.php index 7ffc47a8..d05d3e4c 100644 --- a/migrations/Version20260208131116.php +++ b/migrations/Version20260208131116.php @@ -22,7 +22,7 @@ final class Version20260208131116 extends AbstractMultiPlatformMigration $this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL'); $this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL'); $this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)'); - $this->addSql('ALTER TABLE pricedetails ADD include_vat TINYINT DEFAULT NULL'); + $this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat TINYINT DEFAULT NULL'); } public function mySQLDown(Schema $schema): void @@ -32,7 +32,7 @@ final class Version20260208131116 extends AbstractMultiPlatformMigration $this->addSql('DROP INDEX parts_idx_gtin ON `parts`'); $this->addSql('ALTER TABLE `parts` DROP gtin'); $this->addSql('ALTER TABLE part_lots DROP last_stocktake_at'); - $this->addSql('ALTER TABLE `pricedetails` DROP include_vat'); + $this->addSql('ALTER TABLE `orderdetails` DROP prices_includes_vat'); } public function sqLiteUp(Schema $schema): void @@ -57,7 +57,7 @@ final class Version20260208131116 extends AbstractMultiPlatformMigration $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)'); $this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)'); $this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)'); - $this->addSql('ALTER TABLE pricedetails ADD COLUMN include_vat BOOLEAN DEFAULT NULL'); + $this->addSql('ALTER TABLE orderdetails ADD COLUMN prices_includes_vat BOOLEAN DEFAULT NULL'); } public function sqLiteDown(Schema $schema): void @@ -99,15 +99,14 @@ final class Version20260208131116 extends AbstractMultiPlatformMigration $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)'); $this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)'); $this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)'); - $this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added, id_currency, orderdetails_id FROM "pricedetails"'); - $this->addSql('DROP TABLE "pricedetails"'); - $this->addSql('CREATE TABLE "pricedetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, price NUMERIC(11, 5) NOT NULL, price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES "orderdetails" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); - $this->addSql('INSERT INTO "pricedetails" (id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added, id_currency, orderdetails_id) SELECT id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added, id_currency, orderdetails_id FROM __temp__pricedetails'); - $this->addSql('DROP TABLE __temp__pricedetails'); - $this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON "pricedetails" (id_currency)'); - $this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON "pricedetails" (orderdetails_id)'); - $this->addSql('CREATE INDEX pricedetails_idx_min_discount ON "pricedetails" (min_discount_quantity)'); - $this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON "pricedetails" (min_discount_quantity, price_related_quantity)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM "orderdetails"'); + $this->addSql('DROP TABLE "orderdetails"'); + $this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "orderdetails" (id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier) SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM __temp__orderdetails'); + $this->addSql('DROP TABLE __temp__orderdetails'); + $this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)'); + $this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)'); + $this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)'); } public function postgreSQLUp(Schema $schema): void @@ -116,7 +115,7 @@ final class Version20260208131116 extends AbstractMultiPlatformMigration $this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); $this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL'); $this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)'); - $this->addSql('ALTER TABLE pricedetails ADD include_vat BOOLEAN DEFAULT NULL'); + $this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat BOOLEAN DEFAULT NULL'); } public function postgreSQLDown(Schema $schema): void @@ -125,6 +124,6 @@ final class Version20260208131116 extends AbstractMultiPlatformMigration $this->addSql('ALTER TABLE part_lots DROP last_stocktake_at'); $this->addSql('DROP INDEX parts_idx_gtin'); $this->addSql('ALTER TABLE "parts" DROP gtin'); - $this->addSql('ALTER TABLE "pricedetails" DROP include_vat'); + $this->addSql('ALTER TABLE "orderdetails" DROP prices_includes_vat'); } } diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 9a9a2823..58f69598 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -148,6 +148,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N #[ORM\JoinColumn(name: 'id_supplier')] protected ?Supplier $supplier = null; + /** + * @var bool|null Whether the prices includes VAT or not. Null means, that it is not specified, if the prices includes VAT or not. + */ + #[ORM\Column(type: Types::BOOLEAN, nullable: true)] + #[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])] + protected ?bool $prices_includes_vat = null; + public function __construct() { $this->pricedetails = new ArrayCollection(); @@ -390,45 +397,23 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N } /** - * Checks if the prices of this orderdetail include VAT. This is determined by checking the pricedetails of this - * orderdetail. If there are no pricedetails or if the pricedetails have conflicting values, null is returned. + * Checks if the prices of this orderdetail include VAT. Null means, that it is not specified, if the prices includes + * VAT or not. * @return bool|null */ - #[Groups(['orderdetail:read'])] - #[SerializedName('prices_include_vat')] public function getPricesIncludesVAT(): ?bool { - $value = null; - //We determine that via the pricedetails - foreach ($this->getPricedetails() as $pricedetail) { - /** @var Pricedetail $pricedetail */ - if ($pricedetail->getIncludesVat() === null) { - return null; // If any pricedetail doesn't specify this, we can't determine it - } - - if ($value === null) { - $value = $pricedetail->getIncludesVat(); // Set initial value - } elseif ($value !== $pricedetail->getIncludesVat()) { - return null; // If there are conflicting values, we can't determine it - } - } - - return $value; + return $this->prices_includes_vat; } /** - * Sets whether the prices of this orderdetail include VAT. This is set for all pricedetails of this orderdetail. + * Sets whether the prices of this orderdetail include VAT. * @param bool|null $includesVat * @return $this */ - #[Groups(['orderdetail:write'])] - #[SerializedName('prices_include_vat')] public function setPricesIncludesVAT(?bool $includesVat): self { - foreach ($this->getPricedetails() as $pricedetail) { - /** @var Pricedetail $pricedetail */ - $pricedetail->setIncludesVat($includesVat); - } + $this->prices_includes_vat = $includesVat; return $this; } diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php index 7deb64f9..553b07a3 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -121,12 +121,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface #[Groups(['pricedetail:read:standalone', 'pricedetail:write'])] protected ?Orderdetail $orderdetail = null; - /** - * @var bool|null Whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. - */ - #[ORM\Column(name: "include_vat", type: Types::BOOLEAN, nullable: true)] - #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])] - protected ?bool $includes_vat = null; + public function __construct() { @@ -277,7 +272,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface */ public function getIncludesVat(): ?bool { - return $this->includes_vat; + return $this->orderdetail?->getPricesIncludesVAT(); } /******************************************************************************** @@ -376,15 +371,4 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface return $this; } - - /** - * Set whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. - * @param bool|null $includes_vat - * @return $this - */ - public function setIncludesVat(?bool $includes_vat): self - { - $this->includes_vat = $includes_vat; - return $this; - } } diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php index 2acf3e57..cf1f577d 100644 --- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -39,7 +39,9 @@ readonly class PriceDTO public string $price, /** @var string The currency of the used ISO code of this price detail */ public ?string $currency_iso_code, - /** @var bool If the price includes tax */ + /** @var bool If the price includes tax + * @deprecated Use the prices_include_vat property of the PurchaseInfoDTO instead, as this property is not reliable if there are multiple prices with different values for includes_tax + */ public ?bool $includes_tax = true, /** @var float the price related quantity */ public ?float $price_related_quantity = 1.0, diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php index 9ac142ff..446d04dc 100644 --- a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -29,6 +29,9 @@ namespace App\Services\InfoProviderSystem\DTOs; */ readonly class PurchaseInfoDTO { + /** @var bool|null If the prices contain VAT or not. Null if state is unknown. */ + public ?bool $prices_include_vat; + public function __construct( public string $distributor_name, public string $order_number, @@ -36,6 +39,7 @@ readonly class PurchaseInfoDTO public array $prices, /** @var string|null An url to the product page of the vendor */ public ?string $product_url = null, + ?bool $prices_include_vat = null, ) { //Ensure that the prices are PriceDTO instances @@ -44,5 +48,17 @@ readonly class PurchaseInfoDTO throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances'); } } + + //If no prices_include_vat information is given, try to deduct it from the prices + if ($prices_include_vat === null) { + $vatValues = array_unique(array_map(fn(PriceDTO $price) => $price->includes_tax, $this->prices)); + if (count($vatValues) === 1) { + $this->prices_include_vat = $vatValues[0]; //Use the value of the prices if they are all the same + } else { + $this->prices_include_vat = null; //If there are different values for the prices, we cannot determine if the prices include VAT or not + } + } else { + $this->prices_include_vat = $prices_include_vat; + } } } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 1a93b111..c7c15673 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -100,8 +100,6 @@ final class DTOtoEntityConverter $entity->setCurrency(null); } - $entity->setIncludesVat($dto->includes_tax); - return $entity; } @@ -118,6 +116,8 @@ final class DTOtoEntityConverter $entity->addPricedetail($this->convertPrice($price)); } + $entity->setPricesIncludesVAT($dto->prices_include_vat); + return $entity; } diff --git a/tests/Entity/PriceSystem/OrderdetailTest.php b/tests/Entity/PriceSystem/OrderdetailTest.php index 7eae93aa..df86ab34 100644 --- a/tests/Entity/PriceSystem/OrderdetailTest.php +++ b/tests/Entity/PriceSystem/OrderdetailTest.php @@ -62,76 +62,17 @@ class OrderdetailTest extends TestCase $this->assertSame($price5, $orderdetail->findPriceForQty(10000)); } - public function testGetPricesIncludesVAT(): void + public function testGetSetPricesIncludesVAT(): void { $orderdetail = new Orderdetail(); //By default, the pricesIncludesVAT property should be null for empty orderdetails $this->assertNull($orderdetail->getPricesIncludesVAT()); - $price0 = (new Pricedetail())->setMinDiscountQuantity(0.23); - $price1 = (new Pricedetail())->setMinDiscountQuantity(1); - $price5 = (new Pricedetail())->setMinDiscountQuantity(5.3); - - $orderdetail->addPricedetail($price0)->addPricedetail($price1)->addPricedetail($price5); - - //With empty pricedetails, the pricesIncludesVAT property should still be null - $this->assertNull($orderdetail->getPricesIncludesVAT()); - - //If all of the pricedetails have the same value for includesVAT, the pricesIncludesVAT property should return this value - $price0->setIncludesVAT(true); - $price1->setIncludesVAT(true); - $price5->setIncludesVAT(true); - $this->assertTrue($orderdetail->getPricesIncludesVAT()); - - $price0->setIncludesVAT(false); - $price1->setIncludesVAT(false); - $price5->setIncludesVAT(false); - $this->assertFalse($orderdetail->getPricesIncludesVAT()); - - //If the pricedetails have different values for includesVAT, the pricesIncludesVAT property should return null - $price0->setIncludesVAT(true); - $price1->setIncludesVAT(false); - $price5->setIncludesVAT(true); - $this->assertNull($orderdetail->getPricesIncludesVAT()); - - //If the pricedetails have different values for includesVAT, the pricesIncludesVAT property should return null, even if one of them is null - $price0->setIncludesVAT(null); - $price1->setIncludesVAT(false); - $price5->setIncludesVAT(false); - $this->assertNull($orderdetail->getPricesIncludesVAT()); - } - - public function testSetPricesIncludesVAT(): void - { - $orderdetail = new Orderdetail(); - $price0 = (new Pricedetail())->setMinDiscountQuantity(0.23); - $price1 = (new Pricedetail())->setMinDiscountQuantity(1); - $price5 = (new Pricedetail())->setMinDiscountQuantity(5.3); - - $orderdetail->addPricedetail($price0)->addPricedetail($price1)->addPricedetail($price5); - - $this->assertNull($orderdetail->getPricesIncludesVAT()); - $orderdetail->setPricesIncludesVAT(true); $this->assertTrue($orderdetail->getPricesIncludesVAT()); - //Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails - foreach ($orderdetail->getPricedetails() as $pricedetail) { - $this->assertTrue($pricedetail->getIncludesVAT()); - } $orderdetail->setPricesIncludesVAT(false); $this->assertFalse($orderdetail->getPricesIncludesVAT()); - //Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails - foreach ($orderdetail->getPricedetails() as $pricedetail) { - $this->assertFalse($pricedetail->getIncludesVAT()); - } - - $orderdetail->setPricesIncludesVAT(null); - $this->assertNull($orderdetail->getPricesIncludesVAT()); - //Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails - foreach ($orderdetail->getPricedetails() as $pricedetail) { - $this->assertNull($pricedetail->getIncludesVAT()); - } } } diff --git a/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php index 14a3c03f..1c909e67 100644 --- a/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php +++ b/tests/Services/InfoProviderSystem/DTOs/PurchaseInfoDTOTest.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\Tests\Services\InfoProviderSystem\DTOs; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use PHPUnit\Framework\TestCase; @@ -33,4 +34,40 @@ class PurchaseInfoDTOTest extends TestCase $this->expectExceptionMessage('The prices array must only contain PriceDTO instances'); new PurchaseInfoDTO('test', 'test', [new \stdClass()]); } + + public function testPricesIncludesVATHandling(): void + { + $pricesTrue = [ + new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: true), + new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: true), + ]; + $pricesFalse = [ + new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: false), + new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: false), + ]; + $pricesMixed = [ + new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: true), + new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: false), + ]; + $pricesNull = [ + new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: null), + new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: null), + ]; + + //If the prices_include_vat parameter is given, use it: + $dto = new PurchaseInfoDTO('test', 'test', $pricesMixed, prices_include_vat: true); + $this->assertTrue($dto->prices_include_vat); + $dto = new PurchaseInfoDTO('test', 'test', $pricesMixed, prices_include_vat: false); + $this->assertFalse($dto->prices_include_vat); + + //If the prices_include_vat parameter is not given, try to deduct it from the prices: + $dto = new PurchaseInfoDTO('test', 'test', $pricesTrue); + $this->assertTrue($dto->prices_include_vat); + $dto = new PurchaseInfoDTO('test', 'test', $pricesFalse); + $this->assertFalse($dto->prices_include_vat); + $dto = new PurchaseInfoDTO('test', 'test', $pricesMixed); + $this->assertNull($dto->prices_include_vat); + $dto = new PurchaseInfoDTO('test', 'test', $pricesNull); + $this->assertNull($dto->prices_include_vat); + } } diff --git a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php index 54878bbf..45ea9984 100644 --- a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php +++ b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php @@ -94,15 +94,12 @@ class DTOtoEntityConverterTest extends WebTestCase minimum_discount_amount: 5, price: "10.0", currency_iso_code: 'EUR', - includes_tax: true, ); $entity = $this->service->convertPrice($dto); //For base currencies, the currency field is null $this->assertNull($entity->getCurrency()); - - $this->assertTrue($entity->getIncludesVat()); } public function testConvertPurchaseInfo(): void @@ -117,6 +114,7 @@ class DTOtoEntityConverterTest extends WebTestCase order_number: 'TestOrderNumber', prices: $prices, product_url: 'https://example.com', + prices_include_vat: true, ); $entity = $this->service->convertPurchaseInfo($dto); @@ -124,6 +122,7 @@ class DTOtoEntityConverterTest extends WebTestCase $this->assertSame($dto->distributor_name, $entity->getSupplier()->getName()); $this->assertSame($dto->order_number, $entity->getSupplierPartNr()); $this->assertEquals($dto->product_url, $entity->getSupplierProductUrl()); + $this->assertTrue($dto->prices_include_vat); } public function testConvertFileWithName(): void From 8ac874379247bd45c8af607c20a1edc0dd353734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 16:54:13 +0100 Subject: [PATCH 16/21] Fixed phpunit tests --- .../InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php index b4dc0dea..ce7564da 100644 --- a/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php +++ b/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php @@ -108,6 +108,7 @@ class BulkSearchResponseDTOTest extends KernelTestCase 'manufacturing_status' => NULL, 'provider_url' => NULL, 'footprint' => NULL, + 'gtin' => NULL, ), 'source_field' => 'mpn', 'source_keyword' => '1234', @@ -129,6 +130,7 @@ class BulkSearchResponseDTOTest extends KernelTestCase 'manufacturing_status' => NULL, 'provider_url' => NULL, 'footprint' => NULL, + 'gtin' => NULL, ), 'source_field' => 'name', 'source_keyword' => '1234', From e5231e29f2ffbd7e933b3e990e03a4fed41100f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 17:13:54 +0100 Subject: [PATCH 17/21] Allow to set a global default if new orderdetails should contain VAT or not --- src/Form/Part/PartBaseType.php | 7 ++++++- .../SystemSettings/LocalizationSettings.php | 13 ++++++++++++- templates/form/extended_bootstrap_layout.html.twig | 11 +++++++++++ templates/parts/edit/edit_form_styles.html.twig | 2 +- translations/messages.en.xlf | 12 ++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 902aff40..2145db93 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\LogSystem\EventCommentNeededHelper; use App\Services\LogSystem\EventCommentType; use App\Settings\MiscSettings\IpnSuggestSettings; +use App\Settings\SystemSettings\LocalizationSettings; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -63,6 +64,7 @@ class PartBaseType extends AbstractType protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper, protected IpnSuggestSettings $ipnSuggestSettings, + private readonly LocalizationSettings $localizationSettings, ) { } @@ -267,6 +269,9 @@ class PartBaseType extends AbstractType 'entity' => $part, ]); + $orderdetailPrototype = new Orderdetail(); + $orderdetailPrototype->setPricesIncludesVAT($this->localizationSettings->pricesIncludeTaxByDefault); + //Orderdetails section $builder->add('orderdetails', CollectionType::class, [ 'entry_type' => OrderdetailType::class, @@ -275,7 +280,7 @@ class PartBaseType extends AbstractType 'allow_delete' => true, 'label' => false, 'by_reference' => false, - 'prototype_data' => new Orderdetail(), + 'prototype_data' => $orderdetailPrototype, 'entry_options' => [ 'measurement_unit' => $part->getPartUnit(), ], diff --git a/src/Settings/SystemSettings/LocalizationSettings.php b/src/Settings/SystemSettings/LocalizationSettings.php index c6780c6c..d0c3ce75 100644 --- a/src/Settings/SystemSettings/LocalizationSettings.php +++ b/src/Settings/SystemSettings/LocalizationSettings.php @@ -25,6 +25,7 @@ namespace App\Settings\SystemSettings; use App\Form\Settings\LanguageMenuEntriesType; use App\Form\Type\LocaleSelectType; +use App\Form\Type\TriStateCheckboxType; use App\Settings\SettingsIcon; use Jbtronics\SettingsBundle\Metadata\EnvVarMode; use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; @@ -46,7 +47,7 @@ class LocalizationSettings #[Assert\Locale()] #[Assert\NotBlank()] #[SettingsParameter(label: new TM("settings.system.localization.locale"), formType: LocaleSelectType::class, - envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)] + envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)] public string $locale = 'en'; #[Assert\Timezone()] @@ -73,4 +74,14 @@ class LocalizationSettings )] #[Assert\All([new Assert\Locale()])] public array $languageMenuEntries = []; + + #[SettingsParameter(label: new TM("settings.system.localization.prices_include_tax_by_default"), + description: new TM("settings.system.localization.prices_include_tax_by_default.description"), + formType: TriStateCheckboxType::class + )] + /** + * Indicates whether prices should include tax by default. This is used when creating new pricedetails. + * Null means that the VAT state should be indetermine by default. + */ + public ?bool $pricesIncludeTaxByDefault = null; } diff --git a/templates/form/extended_bootstrap_layout.html.twig b/templates/form/extended_bootstrap_layout.html.twig index 75e44a15..ecd7caf0 100644 --- a/templates/form/extended_bootstrap_layout.html.twig +++ b/templates/form/extended_bootstrap_layout.html.twig @@ -100,6 +100,17 @@ {%- endif -%} {%- endblock tristate_widget %} +{% block tristate_row -%} + {#--#} +
{#--#} +
+ {{- form_widget(form) -}} + {{- form_help(form) -}} + {{- form_errors(form) -}} +
{#--#} + +{%- endblock tristate_row %} + {%- block choice_widget_collapsed -%} {# Only add the BS5 form-select class if we dont use bootstrap-selectpicker #} {# {% if attr["data-controller"] is defined and attr["data-controller"] not in ["elements--selectpicker"] %} diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index aa68f38a..1856dbff 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -32,7 +32,7 @@ {{ form_row(form.supplierpartnr, {'attr': {'class': 'form-control-sm'}}) }} {{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }} {{ form_widget(form.obsolete) }} - {{ form_row(form.pricesIncludesVAT) }} + {{ form_widget(form.pricesIncludesVAT) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index a776eb9d..36fc10d1 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12455,5 +12455,17 @@ Buerklin-API Authentication server: Excl. VAT + + + settings.system.localization.prices_include_tax_by_default + Prices include VAT by default + + + + + settings.system.localization.prices_include_tax_by_default.description + The default value for newly created purchase infos, if prices include VAT or not. + + From 2f9601364ee58c273e093eda035d878680d39bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 22:23:54 +0100 Subject: [PATCH 18/21] Allow to set stocktake date for part lots --- config/permissions.yaml | 3 ++ src/Form/Part/PartLotType.php | 9 ++++ src/Security/Voter/PartLotVoter.php | 4 +- .../parts/edit/edit_form_styles.html.twig | 1 + templates/parts/info/_part_lots.html.twig | 49 ++++++++++--------- translations/messages.en.xlf | 12 +++++ 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/config/permissions.yaml b/config/permissions.yaml index 0dabf9d3..39e91b57 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co move: label: "perm.parts_stock.move" apiTokenRole: ROLE_API_EDIT + stocktake: + label: "perm.parts_stock.stocktake" + apiTokenRole: ROLE_API_EDIT storelocations: &PART_CONTAINING diff --git a/src/Form/Part/PartLotType.php b/src/Form/Part/PartLotType.php index 7d545340..ae86fb61 100644 --- a/src/Form/Part/PartLotType.php +++ b/src/Form/Part/PartLotType.php @@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType; use App\Form\Type\UserSelectType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -110,6 +111,14 @@ class PartLotType extends AbstractType //Do not remove whitespace chars on the beginning and end of the string 'trim' => false, ]); + + $builder->add('last_stocktake_at', DateTimeType::class, [ + 'label' => 'part_lot.edit.last_stocktake_at', + 'widget' => 'single_text', + 'disabled' => !$this->security->isGranted('@parts_stock.stocktake'), + 'required' => false, + 'empty_data' => null, + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php index 87c3d135..5748f4af 100644 --- a/src/Security/Voter/PartLotVoter.php +++ b/src/Security/Voter/PartLotVoter.php @@ -58,13 +58,13 @@ final class PartLotVoter extends Voter { } - protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move']; + protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move', 'stocktake']; protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $user = $this->helper->resolveUser($token); - if (in_array($attribute, ['withdraw', 'add', 'move'], true)) + if (in_array($attribute, ['withdraw', 'add', 'move', 'stocktake'], true)) { $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote); diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index 1856dbff..844c8700 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -110,6 +110,7 @@ {{ form_row(form.comment) }} {{ form_row(form.owner) }} {{ form_row(form.user_barcode) }} + {{ form_row(form.last_stocktake_at) }}
diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig index 1ef25ae4..f4ee4812 100644 --- a/templates/parts/info/_part_lots.html.twig +++ b/templates/parts/info/_part_lots.html.twig @@ -19,53 +19,56 @@ {% for lot in part.partLots %} + {# @var lot App\Entity\Parts\PartLot #} {{ lot.description }} {% if lot.storageLocation %} {{ helper.structural_entity_link(lot.storageLocation) }} {% else %} - + {% trans %}part_lots.location_unknown{% endtrans %} {% endif %} {% if lot.instockUnknown %} - + {% trans %}part_lots.instock_unknown{% endtrans %} {% else %} {{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }} {% endif %} - -
- {% if lot.owner %} - + + {% if lot.owner %} + {{ helper.user_icon_link(lot.owner) }} -
- {% endif %} - {% if lot.expirationDate %} - + + {% endif %} + {% if lot.expirationDate %} + {{ lot.expirationDate | format_date() }}
- {% endif %} - {% if lot.expired %} -
- + {% endif %} + {% if lot.expired %} + {% trans %}part_lots.is_expired{% endtrans %} - {% endif %} - {% if lot.needsRefill %} -
- - - {% trans %}part_lots.need_refill{% endtrans %} - - {% endif %} -
+ {% endif %} + {% if lot.needsRefill %} + + + {% trans %}part_lots.need_refill{% endtrans %} + + {% endif %} + {% if lot.lastStocktakeAt %} + + + {{ lot.lastStocktakeAt | format_datetime("short") }} + + {% endif %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 36fc10d1..e73caaf2 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12467,5 +12467,17 @@ Buerklin-API Authentication server: The default value for newly created purchase infos, if prices include VAT or not. + + + part_lot.edit.last_stocktake_at + Last stocktake + + + + + perm.parts_stock.stocktake + Stocktake + + From d8fdaa9529224207836f338974556f3803f498a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 23:17:10 +0100 Subject: [PATCH 19/21] Added a modal to stocktake / set part lots amount from info page --- .../pages/part_stocktake_modal_controller.js | 27 ++++++++ src/Controller/PartController.php | 50 +++++++++++++++ src/Entity/LogSystem/PartStockChangeType.php | 4 ++ .../LogSystem/PartStockChangedLogEntry.php | 5 ++ .../Parts/PartLotWithdrawAddHelper.php | 41 ++++++++++++ templates/parts/info/_part_lots.html.twig | 7 ++- .../parts/info/_stocktake_modal.html.twig | 63 +++++++++++++++++++ translations/messages.en.xlf | 30 +++++++++ 8 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 assets/controllers/pages/part_stocktake_modal_controller.js create mode 100644 templates/parts/info/_stocktake_modal.html.twig diff --git a/assets/controllers/pages/part_stocktake_modal_controller.js b/assets/controllers/pages/part_stocktake_modal_controller.js new file mode 100644 index 00000000..7aef2906 --- /dev/null +++ b/assets/controllers/pages/part_stocktake_modal_controller.js @@ -0,0 +1,27 @@ +import {Controller} from "@hotwired/stimulus"; +import {Modal} from "bootstrap"; + +export default class extends Controller +{ + connect() { + this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event)); + } + + _handleModalOpen(event) { + // Button that triggered the modal + const button = event.relatedTarget; + + const amountInput = this.element.querySelector('input[name="amount"]'); + + // Extract info from button attributes + const lotID = button.getAttribute('data-lot-id'); + const lotAmount = button.getAttribute('data-lot-amount'); + + //Find the expected amount field and set the value to the lot amount + const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount'); + expectedAmountInput.textContent = lotAmount; + + //Set the action and lotID inputs in the form + this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID); + } +} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index ef2bae5f..d9fcd7f1 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -54,12 +54,14 @@ use Exception; use Omines\DataTablesBundle\DataTableFactory; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Contracts\Translation\TranslatorInterface; use function Symfony\Component\Translation\t; @@ -463,6 +465,54 @@ final class PartController extends AbstractController ); } + #[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])] + #[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')] + public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper, + Request $request, + ): Response + { + $partLot = $em->find(PartLot::class, $request->request->get('lot_id')); + + //Check that the user is allowed to stocktake the partlot + $this->denyAccessUnlessGranted('stocktake', $partLot); + + if (!$partLot instanceof PartLot) { + throw new \RuntimeException('Part lot not found!'); + } + //Ensure that the partlot belongs to the part + if ($partLot->getPart() !== $part) { + throw new \RuntimeException("The origin partlot does not belong to the part!"); + } + + $actualAmount = (float) $request->request->get('actual_amount'); + $comment = $request->request->get('comment'); + + $timestamp = null; + $timestamp_str = $request->request->getString('timestamp', ''); + //Try to parse the timestamp + if ($timestamp_str !== '') { + $timestamp = new DateTime($timestamp_str); + } + + $withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp); + + //Ensure that the timestamp is not in the future + if ($timestamp !== null && $timestamp > new DateTime("+20min")) { + throw new \LogicException("The timestamp must not be in the future!"); + } + + //Save the changes to the DB + $em->flush(); + $this->addFlash('success', 'part.withdraw.success'); + + //If a redirect was passed, then redirect there + if ($request->request->get('_redirect')) { + return $this->redirect($request->request->get('_redirect')); + } + //Otherwise just redirect to the part page + return $this->redirectToRoute('part_info', ['id' => $part->getID()]); + } + #[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])] public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response { diff --git a/src/Entity/LogSystem/PartStockChangeType.php b/src/Entity/LogSystem/PartStockChangeType.php index f69fe95f..79e4c6da 100644 --- a/src/Entity/LogSystem/PartStockChangeType.php +++ b/src/Entity/LogSystem/PartStockChangeType.php @@ -28,6 +28,8 @@ enum PartStockChangeType: string case WITHDRAW = "withdraw"; case MOVE = "move"; + case STOCKTAKE = "stock_take"; + /** * Converts the type to a short representation usable in the extra field of the log entry. * @return string @@ -38,6 +40,7 @@ enum PartStockChangeType: string self::ADD => 'a', self::WITHDRAW => 'w', self::MOVE => 'm', + self::STOCKTAKE => 's', }; } @@ -52,6 +55,7 @@ enum PartStockChangeType: string 'a' => self::ADD, 'w' => self::WITHDRAW, 'm' => self::MOVE, + 's' => self::STOCKTAKE, default => throw new \InvalidArgumentException("Invalid short type: $value"), }; } diff --git a/src/Entity/LogSystem/PartStockChangedLogEntry.php b/src/Entity/LogSystem/PartStockChangedLogEntry.php index 1bac9e9f..a46f2ecf 100644 --- a/src/Entity/LogSystem/PartStockChangedLogEntry.php +++ b/src/Entity/LogSystem/PartStockChangedLogEntry.php @@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp); } + public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self + { + return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp); + } + /** * Returns the instock change type of this entry * @return PartStockChangeType diff --git a/src/Services/Parts/PartLotWithdrawAddHelper.php b/src/Services/Parts/PartLotWithdrawAddHelper.php index 34ec4c1d..d6a95b34 100644 --- a/src/Services/Parts/PartLotWithdrawAddHelper.php +++ b/src/Services/Parts/PartLotWithdrawAddHelper.php @@ -197,4 +197,45 @@ final class PartLotWithdrawAddHelper $this->entityManager->remove($origin); } } + + /** + * Perform a stocktake for the given part lot, setting the amount to the given actual amount. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $lot + * @param float $actualAmount + * @param string|null $comment + * @param \DateTimeInterface|null $action_timestamp + * @return void + */ + public function stocktake(PartLot $lot, float $actualAmount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): void + { + if ($actualAmount < 0) { + throw new \InvalidArgumentException('Actual amount must be non-negative'); + } + + $part = $lot->getPart(); + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $actualAmount = round($actualAmount); + } + + $oldAmount = $lot->getAmount(); + //Clear any unknown status when doing a stocktake, as we now have a known amount + $lot->setInstockUnknown(false); + $lot->setAmount($actualAmount); + if ($action_timestamp) { + $lot->setLastStocktakeAt(\DateTimeImmutable::createFromInterface($action_timestamp)); + } else { + $lot->setLastStocktakeAt(new \DateTimeImmutable()); //Use now if no timestamp is given + } + + $event = PartStockChangedLogEntry::stocktake($lot, $oldAmount, $lot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp); + $this->eventLogger->log($event); + + //Apply the comment also to global events, so it gets associated with the elementChanged log entry + if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) { + $this->eventCommentHelper->setMessage($comment); + } + } } diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig index f4ee4812..cfb7190b 100644 --- a/templates/parts/info/_part_lots.html.twig +++ b/templates/parts/info/_part_lots.html.twig @@ -2,6 +2,7 @@ {% import "label_system/dropdown_macro.html.twig" as dropdown %} {% include "parts/info/_withdraw_modal.html.twig" %} +{% include "parts/info/_stocktake_modal.html.twig" %}
@@ -93,12 +94,15 @@ > + + - - {% endfor %} diff --git a/templates/parts/info/_stocktake_modal.html.twig b/templates/parts/info/_stocktake_modal.html.twig new file mode 100644 index 00000000..5e8c1ae5 --- /dev/null +++ b/templates/parts/info/_stocktake_modal.html.twig @@ -0,0 +1,63 @@ + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e73caaf2..bbd96ac6 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12479,5 +12479,35 @@ Buerklin-API Authentication server: Stocktake + + + part.info.stocktake_modal.title + Stocktake lot + + + + + part.info.stocktake_modal.expected_amount + Expected amount + + + + + part.info.stocktake_modal.actual_amount + Actual amount + + + + + log.part_stock_changed.stock_take + Stocktake + + + + + log.element_edited.changed_fields.last_stocktake_at + Last stocktake + + From 3c87fe093204aec51bb384440683f449b9be27b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 23:19:57 +0100 Subject: [PATCH 20/21] Added test for stocktake method on PartLotWithdrawAddHelper --- .../Parts/PartLotWithdrawAddHelperTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php index 697d3983..b033f07e 100644 --- a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php +++ b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php @@ -154,4 +154,19 @@ class PartLotWithdrawAddHelperTest extends WebTestCase $this->assertEqualsWithDelta(5.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON); $this->assertEqualsWithDelta(2.0, $this->partLot3->getAmount(), PHP_FLOAT_EPSILON); } + + public function testStocktake(): void + { + //Stocktake lot 1 to 20 + $this->service->stocktake($this->partLot1, 20, "Test"); + $this->assertEqualsWithDelta(20.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON); + $this->assertNotNull($this->partLot1->getLastStocktakeAt()); //Stocktake date should be set + + //Stocktake lot 2 to 5 + $this->partLot2->setInstockUnknown(true); + $this->service->stocktake($this->partLot2, 0, "Test"); + $this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON); + $this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared + + } } From 35598df354e679ccfc43d2a70384412ce0dff386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 23:24:40 +0100 Subject: [PATCH 21/21] Automatically set the stocktake permission if a user can already add and withdraw from a lot --- src/Entity/UserSystem/PermissionData.php | 2 +- .../UserSystem/PermissionSchemaUpdater.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index 9ebdc9c9..b7d1ff8f 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable /** * The current schema version of the permission data */ - public const CURRENT_SCHEMA_VERSION = 3; + public const CURRENT_SCHEMA_VERSION = 4; /** * Creates a new Permission Data Instance using the given data. diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php index 104800dc..fd85ee7c 100644 --- a/src/Services/UserSystem/PermissionSchemaUpdater.php +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -157,4 +157,20 @@ class PermissionSchemaUpdater $permissions->setPermissionValue('system', 'show_updates', $new_value); } } + + private function upgradeSchemaToVersion4(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection + { + $permissions = $holder->getPermissions(); + + //If the reports.generate permission is not defined yet, set it to the value of reports.read + if (!$permissions->isPermissionSet('parts_stock', 'stocktake')) { + //Set the new permission to true only if both add and withdraw are allowed + $new_value = TrinaryLogicHelper::and( + $permissions->getPermissionValue('parts_stock', 'withdraw'), + $permissions->getPermissionValue('parts_stock', 'add') + ); + + $permissions->setPermissionValue('parts_stock', 'stocktake', $new_value); + } + } }
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }} {# Action for order information #}