diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 14b683e0..048600a9 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/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/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/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/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/migrations/Version20260208131116.php b/migrations/Version20260208131116.php new file mode 100644 index 00000000..d05d3e4c --- /dev/null +++ b/migrations/Version20260208131116.php @@ -0,0 +1,129 @@ +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 orderdetails ADD prices_includes_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 `orderdetails` DROP prices_includes_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 orderdetails ADD COLUMN prices_includes_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__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 + { + $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 orderdetails ADD prices_includes_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 "orderdetails" DROP prices_includes_vat'); + } +} 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/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/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index 259785cb..d4b15ac7 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, @@ -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 22333c16..7a314ffe 100644 --- a/src/Entity/Attachments/AttachmentType.php +++ b/src/Entity/Attachments/AttachmentType.php @@ -134,6 +134,17 @@ class AttachmentType extends AbstractStructuralDBElement #[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)] protected Collection $attachments_with_type; + /** + * @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'])] @@ -184,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 (isset(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/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/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..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; @@ -84,6 +85,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)] + #[ValidGTIN] + protected ?string $gtin = null; + /** * Checks if this part is marked, for that it needs further review. */ @@ -211,4 +220,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/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 8ed76a46..58f69598 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; @@ -147,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(); @@ -388,6 +396,28 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N return $this; } + /** + * 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 + */ + public function getPricesIncludesVAT(): ?bool + { + return $this->prices_includes_vat; + } + + /** + * Sets whether the prices of this orderdetail include VAT. + * @param bool|null $includesVat + * @return $this + */ + public function setPricesIncludesVAT(?bool $includesVat): self + { + $this->prices_includes_vat = $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 86a7bcd5..553b07a3 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -121,6 +121,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface #[Groups(['pricedetail:read:standalone', 'pricedetail:write'])] protected ?Orderdetail $orderdetail = null; + + public function __construct() { $this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION); @@ -264,6 +266,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->orderdetail?->getPricesIncludesVAT(); + } + /******************************************************************************** * * Setters 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/Form/AdminPages/AttachmentTypeAdminForm.php b/src/Form/AdminPages/AttachmentTypeAdminForm.php index d777d4d4..7f9e7646 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->typeLabelPlural($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/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/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/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/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index b8276589..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, ) { } @@ -216,7 +218,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, [ @@ -261,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, @@ -269,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/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/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/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/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/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/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/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..c7c15673 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -94,7 +94,6 @@ 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 { @@ -117,6 +116,8 @@ final class DTOtoEntityConverter $entity->addPricedetail($this->convertPrice($price)); } + $entity->setPricesIncludesVAT($dto->prices_include_vat); + return $entity; } @@ -175,6 +176,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/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/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/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index 5c8efbf1..88bf33cb 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,15 @@ 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(); + break; + } + } + + //Create purchase info $purchaseInfo = new PurchaseInfoDTO( distributor_name: self::DISTRIBUTOR_NAME, @@ -167,10 +179,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 +286,7 @@ class ReicheltProvider implements InfoProviderInterface ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::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..30aaa223 --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/GTINBarcodeScanResult.php @@ -0,0 +1,62 @@ +. + */ + +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', + ]; + } + + /** + * 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/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/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); + } + } } 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); } 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/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/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/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/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/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/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/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/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index c2a89b6a..844c8700 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_widget(form.pricesIncludesVAT) }}
@@ -109,6 +110,7 @@ {{ form_row(form.comment) }} {{ form_row(form.owner) }} {{ form_row(form.user_barcode) }} + {{ form_row(form.last_stocktake_at) }}
@@ -226,4 +228,4 @@ {{ form_errors(form) }} -{% endblock %} \ No newline at end of file +{% endblock %} 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 + 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/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig index 1ef25ae4..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" %}
@@ -19,53 +20,56 @@ {% for lot in part.partLots %} + {# @var lot App\Entity\Parts\PartLot #} - - - {% endfor %} 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/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/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/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/tests/Entity/PriceSystem/OrderdetailTest.php b/tests/Entity/PriceSystem/OrderdetailTest.php index 497f9ab3..df86ab34 100644 --- a/tests/Entity/PriceSystem/OrderdetailTest.php +++ b/tests/Entity/PriceSystem/OrderdetailTest.php @@ -61,4 +61,18 @@ class OrderdetailTest extends TestCase $this->assertSame($price5, $orderdetail->findPriceForQty(5.3)); $this->assertSame($price5, $orderdetail->findPriceForQty(10000)); } + + public function testGetSetPricesIncludesVAT(): void + { + $orderdetail = new Orderdetail(); + + //By default, the pricesIncludesVAT property should be null for empty orderdetails + $this->assertNull($orderdetail->getPricesIncludesVAT()); + + $orderdetail->setPricesIncludesVAT(true); + $this->assertTrue($orderdetail->getPricesIncludesVAT()); + + $orderdetail->setPricesIncludesVAT(false); + $this->assertFalse($orderdetail->getPricesIncludesVAT()); + } } 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', 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 6c933472..45ea9984 100644 --- a/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php +++ b/tests/Services/InfoProviderSystem/DTOtoEntityConverterTest.php @@ -94,7 +94,6 @@ class DTOtoEntityConverterTest extends WebTestCase minimum_discount_amount: 5, price: "10.0", currency_iso_code: 'EUR', - includes_tax: true, ); $entity = $this->service->convertPrice($dto); @@ -115,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); @@ -122,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 @@ -159,12 +160,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 +182,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()); 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 + + } } 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..bbd96ac6 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12401,5 +12401,113 @@ Buerklin-API Authentication server: Update to + + + part.gtin + GTIN / EAN + + + + + info_providers.capabilities.gtin + GTIN / EAN + + + + + part.table.gtin + GTIN + + + + + scan_dialog.mode.gtin + 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. + + + + + orderdetails.edit.prices_includes_vat + Prices include VAT + + + + + prices.incl_vat + Incl. VAT + + + + + prices.excl_vat + 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. + + + + + part_lot.edit.last_stocktake_at + Last stocktake + + + + + perm.parts_stock.stocktake + 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 + + - \ 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 +
{{ 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 %}
@@ -90,12 +94,15 @@ > + +
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }} {# Action for order information #}