diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 2a110f8d..488c6421 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -26,6 +26,7 @@ export default class extends Controller { static values = { deleteMessage: String, prototype: String, + rowsToDelete: Number, //How many rows (including the current one) shall be deleted after the current row } static targets = ["target"]; @@ -125,7 +126,17 @@ export default class extends Controller { const del = () => { const target = event.target; //Remove the row element from the table - target.closest("tr").remove(); + const current_row = target.closest("tr"); + for(let i = this.rowsToDeleteValue; i > 1; i--) { + let nextSibling = current_row.nextElementSibling; + //Ensure that nextSibling is really a tr + if (nextSibling && nextSibling.tagName === "TR") { + nextSibling.remove(); + } + } + + //Finally delete the current row + current_row.remove(); } if(this.deleteMessageValue) { diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js new file mode 100644 index 00000000..ee13b4b9 --- /dev/null +++ b/assets/controllers/elements/part_select_controller.js @@ -0,0 +1,68 @@ +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/components/tom-select_extensions.css'; +import TomSelect from "tom-select"; +import {marked} from "marked"; + +export default class extends Controller { + _tomSelect; + + connect() { + + let settings = { + allowEmptyOption: true, + plugins: ['dropdown_input'], + searchField: ["name", "description", "category", "footprint"], + valueField: "id", + labelField: "name", + preload: "focus", + render: { + item: (data, escape) => { + return '' + (data.image ? "" : "") + escape(data.name) + ''; + }, + option: (data, escape) => { + if(data.text) { + return '' + escape(data.text) + ''; + } + + let tmp = '
' + + "
" + + (data.image ? "" : "") + + "
" + + "
" + + '
' + escape(data.name) + '
' + + (data.description ? '

' + marked.parseInline(data.description) + '

' : "") + + (data.category ? '

' + escape(data.category) : ""); + + if (data.footprint) { //If footprint is defined for the part show it next to the category + tmp += ' ' + escape(data.footprint); + } + + return tmp + '

' + + '
'; + } + } + }; + + + if (this.element.dataset.autocomplete) { + const base_url = this.element.dataset.autocomplete; + settings.valueField = "id"; + settings.load = (query, callback) => { + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => {callback(json);}) + .catch(() => { + callback() + }); + }; + + + this._tomSelect = new TomSelect(this.element, settings); + //this._tomSelect.clearOptions(); + } + } +} \ No newline at end of file diff --git a/assets/controllers/pages/part_withdraw_modal_controller.js b/assets/controllers/pages/part_withdraw_modal_controller.js new file mode 100644 index 00000000..2d6742b4 --- /dev/null +++ b/assets/controllers/pages/part_withdraw_modal_controller.js @@ -0,0 +1,64 @@ +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 action = button.getAttribute('data-action'); + const lotID = button.getAttribute('data-lot-id'); + const lotAmount = button.getAttribute('data-lot-amount'); + + //Set the action and lotID inputs in the form + this.element.querySelector('input[name="action"]').setAttribute('value', action); + this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID); + + //Set the title + const titleElement = this.element.querySelector('.modal-title'); + switch (action) { + case 'withdraw': + titleElement.innerText = titleElement.getAttribute('data-withdraw'); + break; + case 'add': + titleElement.innerText = titleElement.getAttribute('data-add'); + break; + case 'move': + titleElement.innerText = titleElement.getAttribute('data-move'); + break; + } + + //Hide the move to lot select, if the action is not move (and unhide it, if it is) + const moveToLotSelect = this.element.querySelector('#withdraw-modal-move-to'); + if (action === 'move') { + moveToLotSelect.classList.remove('d-none'); + } else { + moveToLotSelect.classList.add('d-none'); + } + + //First unhide all move to lot options and then hide the currently selected lot + const moveToLotOptions = moveToLotSelect.querySelectorAll('input[type="radio"]'); + moveToLotOptions.forEach(option => option.parentElement.classList.remove('d-none')); + moveToLotOptions.forEach(option => { + if (option.getAttribute('value') === lotID) { + option.parentElement.classList.add('d-none'); + option.selected = false; + } + }); + + //For adding parts there is no limit on the amount to add + if (action == 'add') { + amountInput.removeAttribute('max'); + } else { //Every other action is limited to the amount of parts in the lot + amountInput.setAttribute('max', lotAmount); + } + } +} \ No newline at end of file diff --git a/assets/js/register_events.js b/assets/js/register_events.js index 9b96a5c6..77df178f 100644 --- a/assets/js/register_events.js +++ b/assets/js/register_events.js @@ -22,8 +22,17 @@ class RegisterEventHelper { constructor() { this.registerTooltips(); - this.registerSpecialCharInput(); + + this.registerModalDropRemovalOnFormSubmit() + } + + + registerModalDropRemovalOnFormSubmit() { + //Remove all modal backdrops, before rendering the new page. + document.addEventListener('turbo:before-render', event => { + document.querySelector('.modal-backdrop').remove(); + }); } registerLoadHandler(fn) { @@ -35,7 +44,7 @@ class RegisterEventHelper { this.registerLoadHandler(() => { $(".tooltip").remove(); //Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.) - $('a[title], button[title]:not([data-bs-toggle="dropdown"]), span[title], h6[title], h3[title], i.fas[title]') + $('a[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i.fas[title]') //@ts-ignore .tooltip("hide").tooltip({container: "body", placement: "auto", boundary: 'window'}); }); diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index f1435e9d..bc31bb8f 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -8,11 +8,11 @@ doctrine: types: datetime: - class: App\Helpers\UTCDateTimeType + class: App\Doctrine\Types\UTCDateTimeType date: - class: App\Helpers\UTCDateTimeType + class: App\Doctrine\Types\UTCDateTimeType big_decimal: - class: App\Helpers\BigDecimalType + class: App\Doctrine\Types\BigDecimalType schema_filter: ~^(?!internal)~ # Only enable this when needed diff --git a/config/permissions.yaml b/config/permissions.yaml index 27cb5a06..41617666 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -27,7 +27,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co 'currencies.read', 'attachment_types.read', 'measurement_units.read'] edit: label: "perm.edit" - alsoSet: 'read' + alsoSet: ['read', 'parts_stock.withdraw', 'parts_stock.add', 'parts_stock.move'] create: label: "perm.create" alsoSet: ['read', 'edit'] @@ -44,6 +44,18 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co label: "perm.revert_elements" alsoSet: ["read", "edit", "create", "delete", "show_history"] + parts_stock: + group: "data" + label: "perm.parts_stock" + operations: + withdraw: + label: "perm.parts_stock.withdraw" + add: + label: "perm.parts_stock.add" + move: + label: "perm.parts_stock.move" + + storelocations: &PART_CONTAINING label: "perm.storelocations" group: "data" @@ -81,9 +93,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co <<: *PART_CONTAINING label: "perm.part.manufacturers" - devices: + projects: <<: *PART_CONTAINING - label: "perm.part.devices" + label: "perm.projects" attachment_types: <<: *PART_CONTAINING diff --git a/migrations/Version20230108165410.php b/migrations/Version20230108165410.php new file mode 100644 index 00000000..637ab437 --- /dev/null +++ b/migrations/Version20230108165410.php @@ -0,0 +1,321 @@ +addSql('ALTER TABLE devices RENAME TO projects'); + $this->addSql('ALTER TABLE device_parts RENAME TO project_bom_entries'); + + $this->addSql('ALTER TABLE parts ADD built_project_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE parts ADD CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)'); + $this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_AFC547992F180363'); + $this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_AFC54799C22F6CC4'); + $this->addSql('ALTER TABLE project_bom_entries ADD price_currency_id INT DEFAULT NULL, ADD name VARCHAR(255) DEFAULT NULL, ADD comment LONGTEXT NOT NULL, ADD price NUMERIC(11, 5) DEFAULT NULL COMMENT \'(DC2Type:big_decimal)\', ADD last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, ADD datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE quantity quantity DOUBLE PRECISION NOT NULL'); + $this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_1AA2DD313FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id)'); + $this->addSql('CREATE INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries (price_currency_id)'); + $this->addSql('DROP INDEX idx_afc547992f180363 ON project_bom_entries'); + $this->addSql('CREATE INDEX IDX_1AA2DD312F180363 ON project_bom_entries (id_device)'); + $this->addSql('DROP INDEX idx_afc54799c22f6cc4 ON project_bom_entries'); + $this->addSql('CREATE INDEX IDX_1AA2DD31C22F6CC4 ON project_bom_entries (id_part)'); + $this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_AFC547992F180363 FOREIGN KEY (id_device) REFERENCES projects (id)'); + $this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_AFC54799C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id)'); + $this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_11074E9A6DEDCEC2'); + + //On old DBs migrated from the legacy Part-DB version the key devices_parent_id_fk is still existing + if ($this->getOldDBVersion() === 99) { + $this->addSql('ALTER TABLE projects DROP FOREIGN KEY devices_parent_id_fk'); + } + $this->addSql('ALTER TABLE projects ADD status VARCHAR(64) DEFAULT NULL, ADD description LONGTEXT NOT NULL'); + $this->addSql('ALTER TABLE projects RENAME INDEX idx_11074e9a727aca70 TO IDX_5C93B3A4727ACA70'); + $this->addSql('ALTER TABLE projects RENAME INDEX idx_11074e9a6dedcec2 TO IDX_5C93B3A46DEDCEC2'); + $this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_11074E9A6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id)'); + $this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id)'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE `parts` DROP FOREIGN KEY FK_6940A7FEE8AE70D9'); + $this->addSql('DROP INDEX UNIQ_6940A7FEE8AE70D9 ON `parts`'); + $this->addSql('ALTER TABLE `parts` DROP built_project_id'); + $this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A4727ACA70'); + $this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A46DEDCEC2'); + $this->addSql('ALTER TABLE projects DROP status, DROP description'); + $this->addSql('DROP INDEX idx_5c93b3a4727aca70 ON projects'); + $this->addSql('CREATE INDEX IDX_11074E9A727ACA70 ON projects (parent_id)'); + $this->addSql('DROP INDEX idx_5c93b3a46dedcec2 ON projects'); + $this->addSql('CREATE INDEX IDX_11074E9A6DEDCEC2 ON projects (id_preview_attachement)'); + $this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id)'); + $this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A46DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES `attachments` (id)'); + $this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_1AA2DD313FFDCD60'); + $this->addSql('DROP INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries'); + $this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_1AA2DD312F180363'); + $this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_1AA2DD31C22F6CC4'); + $this->addSql('ALTER TABLE project_bom_entries DROP price_currency_id, DROP name, DROP comment, DROP price, DROP last_modified, DROP datetime_added, CHANGE quantity quantity INT NOT NULL'); + $this->addSql('DROP INDEX idx_1aa2dd312f180363 ON project_bom_entries'); + $this->addSql('CREATE INDEX IDX_AFC547992F180363 ON project_bom_entries (id_device)'); + $this->addSql('DROP INDEX idx_1aa2dd31c22f6cc4 ON project_bom_entries'); + $this->addSql('CREATE INDEX IDX_AFC54799C22F6CC4 ON project_bom_entries (id_part)'); + $this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_1AA2DD312F180363 FOREIGN KEY (id_device) REFERENCES projects (id)'); + $this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_1AA2DD31C22F6CC4 FOREIGN KEY (id_part) REFERENCES `parts` (id)'); + + $this->addSql('ALTER TABLE projects RENAME TO devices'); + $this->addSql('ALTER TABLE project_bom_entries RENAME TO device_parts'); + } + + public function sqLiteUp(Schema $schema): void + { + //Rename the table from devices to projects + $this->addSql('ALTER TABLE devices RENAME TO projects'); + $this->addSql('ALTER TABLE device_parts RENAME TO project_bom_entries'); + + $this->addSql('CREATE TEMPORARY TABLE __temp__currencies AS SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM currencies'); + $this->addSql('DROP TABLE currencies'); + $this->addSql('CREATE TABLE currencies (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, exchange_rate NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal) + , iso_code VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_37C44693727ACA70 FOREIGN KEY (parent_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_37C446936DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO currencies (id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM __temp__currencies'); + $this->addSql('DROP TABLE __temp__currencies'); + $this->addSql('CREATE INDEX currency_idx_parent_name ON currencies (parent_id, name)'); + $this->addSql('CREATE INDEX currency_idx_name ON currencies (name)'); + $this->addSql('CREATE INDEX IDX_37C44693727ACA70 ON currencies (parent_id)'); + $this->addSql('CREATE INDEX IDX_37C446936DEDCEC2 ON currencies (id_preview_attachement)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__groups AS SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM groups'); + $this->addSql('DROP TABLE groups'); + $this->addSql('CREATE TABLE groups (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, enforce_2fa BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL --(DC2Type:json) + , CONSTRAINT FK_F06D3970727ACA70 FOREIGN KEY (parent_id) REFERENCES groups (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F06D39706DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO groups (id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data) SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM __temp__groups'); + $this->addSql('DROP TABLE __temp__groups'); + $this->addSql('CREATE INDEX IDX_F06D39706DEDCEC2 ON groups (id_preview_attachement)'); + $this->addSql('CREATE INDEX IDX_F06D3970727ACA70 ON groups (parent_id)'); + $this->addSql('CREATE INDEX group_idx_name ON groups (name)'); + $this->addSql('CREATE INDEX group_idx_parent_name ON groups (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__log AS SELECT id, id_user, datetime, level, target_id, target_type, extra, type, username FROM log'); + $this->addSql('DROP TABLE log'); + $this->addSql('CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_user INTEGER DEFAULT NULL, datetime DATETIME NOT NULL, level TINYINT(4) NOT NULL, target_id INTEGER NOT NULL, target_type SMALLINT NOT NULL, extra CLOB NOT NULL --(DC2Type:json) + , type SMALLINT NOT NULL, username VARCHAR(255) NOT NULL, CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO log (id, id_user, datetime, level, target_id, target_type, extra, type, username) SELECT id, id_user, datetime, level, target_id, target_type, extra, type, username FROM __temp__log'); + $this->addSql('DROP TABLE __temp__log'); + $this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)'); + $this->addSql('CREATE INDEX log_idx_type ON log (type)'); + $this->addSql('CREATE INDEX log_idx_type_target ON log (type, target_type, target_id)'); + $this->addSql('CREATE INDEX log_idx_datetime ON log (datetime)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_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 FROM parts'); + $this->addSql('DROP TABLE parts'); + $this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachement 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, 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 VARCHAR(255) 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, CONSTRAINT FK_6940A7FE6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION 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_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) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO parts (id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_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) SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_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 FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)'); + $this->addSql('CREATE INDEX parts_idx_name ON parts (name)'); + $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 IDX_6940A7FE6DEDCEC2 ON parts (id_preview_attachement)'); + $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 TEMPORARY TABLE __temp__pricedetails AS SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM pricedetails'); + $this->addSql('DROP TABLE pricedetails'); + $this->addSql('CREATE TABLE pricedetails (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, price NUMERIC(11, 5) NOT NULL --(DC2Type:big_decimal) + , price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO pricedetails (id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added) SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM __temp__pricedetails'); + $this->addSql('DROP TABLE __temp__pricedetails'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON pricedetails (min_discount_quantity, price_related_quantity)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount ON pricedetails (min_discount_quantity)'); + $this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON pricedetails (id_currency)'); + $this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON pricedetails (orderdetails_id)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__project_bom_entries AS SELECT id, id_device, id_part, quantity, mountnames FROM project_bom_entries'); + $this->addSql('DROP TABLE project_bom_entries'); + $this->addSql('CREATE TABLE project_bom_entries (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_device INTEGER DEFAULT NULL, id_part INTEGER DEFAULT NULL, price_currency_id INTEGER DEFAULT NULL, quantity DOUBLE PRECISION NOT NULL, mountnames CLOB NOT NULL, name VARCHAR(255) DEFAULT NULL, comment CLOB NOT NULL, price NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal) + , last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AFC547992F180363 FOREIGN KEY (id_device) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AFC54799C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD313FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO project_bom_entries (id, id_device, id_part, quantity, mountnames) SELECT id, id_device, id_part, quantity, mountnames FROM __temp__project_bom_entries'); + $this->addSql('DROP TABLE __temp__project_bom_entries'); + $this->addSql('CREATE INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries (price_currency_id)'); + $this->addSql('CREATE INDEX IDX_1AA2DD312F180363 ON project_bom_entries (id_device)'); + $this->addSql('CREATE INDEX IDX_1AA2DD31C22F6CC4 ON project_bom_entries (id_part)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__projects AS SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM projects'); + $this->addSql('DROP TABLE projects'); + $this->addSql('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, order_quantity INTEGER NOT NULL, order_only_missing_parts BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, status VARCHAR(64) DEFAULT NULL, description CLOB NOT NULL DEFAULT "", CONSTRAINT FK_11074E9A727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_11074E9A6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO projects (id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM __temp__projects'); + $this->addSql('DROP TABLE __temp__projects'); + $this->addSql('CREATE INDEX IDX_5C93B3A4727ACA70 ON projects (parent_id)'); + $this->addSql('CREATE INDEX IDX_5C93B3A46DEDCEC2 ON projects (id_preview_attachement)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM suppliers'); + $this->addSql('DROP TABLE suppliers'); + $this->addSql('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal) + , address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES suppliers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95C6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO suppliers (id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__suppliers'); + $this->addSql('DROP TABLE __temp__suppliers'); + $this->addSql('CREATE INDEX supplier_idx_parent_name ON suppliers (parent_id, name)'); + $this->addSql('CREATE INDEX supplier_idx_name ON suppliers (name)'); + $this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON suppliers (parent_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON suppliers (default_currency_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95C6DEDCEC2 ON suppliers (id_preview_attachement)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__users AS SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM users'); + $this->addSql('DROP TABLE users'); + $this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, group_id INTEGER DEFAULT NULL, currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, disabled BOOLEAN NOT NULL, config_theme VARCHAR(255) DEFAULT NULL, pw_reset_token VARCHAR(255) DEFAULT NULL, config_instock_comment_a CLOB NOT NULL, config_instock_comment_w CLOB NOT NULL, trusted_device_cookie_version INTEGER NOT NULL, backup_codes CLOB NOT NULL --(DC2Type:json) + , google_authenticator_secret VARCHAR(255) DEFAULT NULL, config_timezone VARCHAR(255) DEFAULT NULL, config_language VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, need_pw_change BOOLEAN NOT NULL, password VARCHAR(255) DEFAULT NULL, name VARCHAR(180) NOT NULL, settings CLOB NOT NULL --(DC2Type:json) + , backup_codes_generation_date DATETIME DEFAULT NULL, pw_reset_expires DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL --(DC2Type:json) + , CONSTRAINT FK_1483A5E9FE54D947 FOREIGN KEY (group_id) REFERENCES groups (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E938248176 FOREIGN KEY (currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E96DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO users (id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data) SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM __temp__users'); + $this->addSql('DROP TABLE __temp__users'); + $this->addSql('CREATE INDEX IDX_1483A5E96DEDCEC2 ON users (id_preview_attachement)'); + $this->addSql('CREATE INDEX IDX_1483A5E938248176 ON users (currency_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E9FE54D947 ON users (group_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E95E237E06 ON users (name)'); + $this->addSql('CREATE INDEX user_idx_username ON users (name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__webauthn_keys AS SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM webauthn_keys'); + $this->addSql('DROP TABLE webauthn_keys'); + $this->addSql('CREATE TABLE webauthn_keys (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, public_key_credential_id CLOB NOT NULL --(DC2Type:base64) + , type VARCHAR(255) NOT NULL, transports CLOB NOT NULL --(DC2Type:array) + , attestation_type VARCHAR(255) NOT NULL, trust_path CLOB NOT NULL --(DC2Type:trust_path) + , aaguid CLOB NOT NULL --(DC2Type:aaguid) + , credential_public_key CLOB NOT NULL --(DC2Type:base64) + , user_handle VARCHAR(255) NOT NULL, counter INTEGER NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_799FD143A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO webauthn_keys (id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added) SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM __temp__webauthn_keys'); + $this->addSql('DROP TABLE __temp__webauthn_keys'); + $this->addSql('CREATE INDEX IDX_799FD143A76ED395 ON webauthn_keys (user_id)'); + } + + public function sqLiteDown(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TEMPORARY TABLE __temp__currencies AS SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM currencies'); + $this->addSql('DROP TABLE currencies'); + $this->addSql('CREATE TABLE currencies (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, exchange_rate NUMERIC(11, 5) DEFAULT NULL -- +(DC2Type:big_decimal) + , iso_code VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_37C44693727ACA70 FOREIGN KEY (parent_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_37C446936DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO currencies (id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM __temp__currencies'); + $this->addSql('DROP TABLE __temp__currencies'); + $this->addSql('CREATE INDEX IDX_37C44693727ACA70 ON currencies (parent_id)'); + $this->addSql('CREATE INDEX IDX_37C446936DEDCEC2 ON currencies (id_preview_attachement)'); + $this->addSql('CREATE INDEX currency_idx_name ON currencies (name)'); + $this->addSql('CREATE INDEX currency_idx_parent_name ON currencies (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__groups AS SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM "groups"'); + $this->addSql('DROP TABLE "groups"'); + $this->addSql('CREATE TABLE "groups" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, enforce_2fa BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL -- +(DC2Type:json) + , CONSTRAINT FK_F06D3970727ACA70 FOREIGN KEY (parent_id) REFERENCES "groups" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F06D39706DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "groups" (id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data) SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM __temp__groups'); + $this->addSql('DROP TABLE __temp__groups'); + $this->addSql('CREATE INDEX IDX_F06D3970727ACA70 ON "groups" (parent_id)'); + $this->addSql('CREATE INDEX IDX_F06D39706DEDCEC2 ON "groups" (id_preview_attachement)'); + $this->addSql('CREATE INDEX group_idx_name ON "groups" (name)'); + $this->addSql('CREATE INDEX group_idx_parent_name ON "groups" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__log AS SELECT id, id_user, username, datetime, level, target_id, target_type, extra, type FROM log'); + $this->addSql('DROP TABLE log'); + $this->addSql('CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_user INTEGER DEFAULT NULL, username VARCHAR(255) DEFAULT \'""\' NOT NULL, datetime DATETIME NOT NULL, level BOOLEAN NOT NULL, target_id INTEGER NOT NULL, target_type SMALLINT NOT NULL, extra CLOB NOT NULL --(DC2Type:json) + , type SMALLINT NOT NULL, CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO log (id, id_user, username, datetime, level, target_id, target_type, extra, type) SELECT id, id_user, username, datetime, level, target_id, target_type, extra, type FROM __temp__log'); + $this->addSql('DROP TABLE __temp__log'); + $this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)'); + $this->addSql('CREATE INDEX log_idx_type ON log (type)'); + $this->addSql('CREATE INDEX log_idx_type_target ON log (type, target_type, target_id)'); + $this->addSql('CREATE INDEX log_idx_datetime ON log (datetime)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order FROM "parts"'); + $this->addSql('DROP TABLE "parts"'); + $this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachement 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, 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, 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 VARCHAR(255) 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, CONSTRAINT FK_6940A7FE6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (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)'); + $this->addSql('INSERT INTO "parts" (id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order) SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)'); + $this->addSql('CREATE INDEX IDX_6940A7FE6DEDCEC2 ON "parts" (id_preview_attachement)'); + $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 INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM "pricedetails"'); + $this->addSql('DROP TABLE "pricedetails"'); + $this->addSql('CREATE TABLE "pricedetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, price NUMERIC(11, 5) NOT NULL -- +(DC2Type:big_decimal) + , price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES "orderdetails" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "pricedetails" (id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added) SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM __temp__pricedetails'); + $this->addSql('DROP TABLE __temp__pricedetails'); + $this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON "pricedetails" (id_currency)'); + $this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON "pricedetails" (orderdetails_id)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount ON "pricedetails" (min_discount_quantity)'); + $this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON "pricedetails" (min_discount_quantity, price_related_quantity)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__project_bom_entries AS SELECT id, id_device, id_part, quantity, mountnames FROM project_bom_entries'); + $this->addSql('DROP TABLE project_bom_entries'); + $this->addSql('CREATE TABLE project_bom_entries (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_device INTEGER DEFAULT NULL, id_part INTEGER DEFAULT NULL, quantity INTEGER NOT NULL, mountnames CLOB NOT NULL, CONSTRAINT FK_1AA2DD312F180363 FOREIGN KEY (id_device) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD31C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO project_bom_entries (id, id_device, id_part, quantity, mountnames) SELECT id, id_device, id_part, quantity, mountnames FROM __temp__project_bom_entries'); + $this->addSql('DROP TABLE __temp__project_bom_entries'); + $this->addSql('CREATE INDEX IDX_AFC547992F180363 ON project_bom_entries (id_device)'); + $this->addSql('CREATE INDEX IDX_AFC54799C22F6CC4 ON project_bom_entries (id_part)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__projects AS SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM projects'); + $this->addSql('DROP TABLE projects'); + $this->addSql('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, order_quantity INTEGER NOT NULL, order_only_missing_parts BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_5C93B3A46DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO projects (id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM __temp__projects'); + $this->addSql('DROP TABLE __temp__projects'); + $this->addSql('CREATE INDEX IDX_11074E9A727ACA70 ON projects (parent_id)'); + $this->addSql('CREATE INDEX IDX_11074E9A6DEDCEC2 ON projects (id_preview_attachement)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM "suppliers"'); + $this->addSql('DROP TABLE "suppliers"'); + $this->addSql('CREATE TABLE "suppliers" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL -- +(DC2Type:big_decimal) + , address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95C6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "suppliers" (id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__suppliers'); + $this->addSql('DROP TABLE __temp__suppliers'); + $this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON "suppliers" (parent_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON "suppliers" (default_currency_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95C6DEDCEC2 ON "suppliers" (id_preview_attachement)'); + $this->addSql('CREATE INDEX supplier_idx_name ON "suppliers" (name)'); + $this->addSql('CREATE INDEX supplier_idx_parent_name ON "suppliers" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__users AS SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM "users"'); + $this->addSql('DROP TABLE "users"'); + $this->addSql('CREATE TABLE "users" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, group_id INTEGER DEFAULT NULL, currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, disabled BOOLEAN NOT NULL, config_theme VARCHAR(255) DEFAULT NULL, pw_reset_token VARCHAR(255) DEFAULT NULL, config_instock_comment_a CLOB NOT NULL, config_instock_comment_w CLOB NOT NULL, trusted_device_cookie_version INTEGER NOT NULL, backup_codes CLOB NOT NULL -- +(DC2Type:json) + , google_authenticator_secret VARCHAR(255) DEFAULT NULL, config_timezone VARCHAR(255) DEFAULT NULL, config_language VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, need_pw_change BOOLEAN NOT NULL, password VARCHAR(255) DEFAULT NULL, name VARCHAR(180) NOT NULL, settings CLOB NOT NULL -- +(DC2Type:json) + , backup_codes_generation_date DATETIME DEFAULT NULL, pw_reset_expires DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL -- +(DC2Type:json) + , CONSTRAINT FK_1483A5E9FE54D947 FOREIGN KEY (group_id) REFERENCES "groups" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E938248176 FOREIGN KEY (currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E96DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "users" (id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data) SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM __temp__users'); + $this->addSql('DROP TABLE __temp__users'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E95E237E06 ON "users" (name)'); + $this->addSql('CREATE INDEX IDX_1483A5E9FE54D947 ON "users" (group_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E938248176 ON "users" (currency_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E96DEDCEC2 ON "users" (id_preview_attachement)'); + $this->addSql('CREATE INDEX user_idx_username ON "users" (name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__webauthn_keys AS SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM webauthn_keys'); + $this->addSql('DROP TABLE webauthn_keys'); + $this->addSql('CREATE TABLE webauthn_keys (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, public_key_credential_id CLOB NOT NULL -- +(DC2Type:base64) + , type VARCHAR(255) NOT NULL, transports CLOB NOT NULL -- +(DC2Type:array) + , attestation_type VARCHAR(255) NOT NULL, trust_path CLOB NOT NULL -- +(DC2Type:trust_path) + , aaguid CLOB NOT NULL -- +(DC2Type:aaguid) + , credential_public_key CLOB NOT NULL -- +(DC2Type:base64) + , user_handle VARCHAR(255) NOT NULL, counter INTEGER NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_799FD143A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO webauthn_keys (id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added) SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM __temp__webauthn_keys'); + $this->addSql('DROP TABLE __temp__webauthn_keys'); + $this->addSql('CREATE INDEX IDX_799FD143A76ED395 ON webauthn_keys (user_id)'); + + $this->addSql('ALTER TABLE projects RENAME TO devices'); + $this->addSql('ALTER TABLE project_bom_entries RENAME TO device_parts'); + } +} diff --git a/src/Command/Migrations/ConvertBBCodeCommand.php b/src/Command/Migrations/ConvertBBCodeCommand.php index 0712f6fd..6b2b3fd7 100644 --- a/src/Command/Migrations/ConvertBBCodeCommand.php +++ b/src/Command/Migrations/ConvertBBCodeCommand.php @@ -24,7 +24,7 @@ namespace App\Command\Migrations; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractNamedDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; @@ -94,7 +94,7 @@ class ConvertBBCodeCommand extends Command Part::class => ['description', 'comment'], AttachmentType::class => ['comment'], Storelocation::class => ['comment'], - Device::class => ['comment'], + Project::class => ['comment'], Category::class => ['comment'], Manufacturer::class => ['comment'], MeasurementUnit::class => ['comment'], diff --git a/src/Command/User/UpgradePermissionsSchemaCommand.php b/src/Command/User/UpgradePermissionsSchemaCommand.php new file mode 100644 index 00000000..ae5adfff --- /dev/null +++ b/src/Command/User/UpgradePermissionsSchemaCommand.php @@ -0,0 +1,127 @@ +. + */ + +namespace App\Command\User; + +use App\Entity\UserSystem\Group; +use App\Entity\UserSystem\PermissionData; +use App\Entity\UserSystem\User; +use App\Services\LogSystem\EventCommentHelper; +use App\Services\UserSystem\PermissionSchemaUpdater; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +final class UpgradePermissionsSchemaCommand extends Command +{ + protected static $defaultName = 'partdb:users:upgrade-permissions-schema'; + protected static $defaultDescription = '(Manually) upgrades the permissions schema of all users to the latest version.'; + + private PermissionSchemaUpdater $permissionSchemaUpdater; + private EntityManagerInterface $em; + private EventCommentHelper $eventCommentHelper; + + public function __construct(PermissionSchemaUpdater $permissionSchemaUpdater, EntityManagerInterface $entityManager, EventCommentHelper $eventCommentHelper) + { + parent::__construct(self::$defaultName); + + $this->permissionSchemaUpdater = $permissionSchemaUpdater; + $this->eventCommentHelper = $eventCommentHelper; + $this->em = $entityManager; + } + + protected function configure(): void + { + $this + ->setDescription(self::$defaultDescription) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->info('Target schema version number: '. PermissionData::CURRENT_SCHEMA_VERSION); + + //Retrieve all users and groups + $users = $this->em->getRepository(User::class)->findAll(); + $groups = $this->em->getRepository(Group::class)->findAll(); + + //Check which users and groups need an update + $groups_to_upgrade = []; + $users_to_upgrade = []; + foreach ($groups as $group) { + if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($group)) { + $groups_to_upgrade[] = $group; + } + } + foreach ($users as $user) { + if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($user)) { + $users_to_upgrade[] = $user; + } + } + + $io->info('Found '. count($groups_to_upgrade) .' groups and '. count($users_to_upgrade) .' users that need an update.'); + if (empty($groups_to_upgrade) && empty($users_to_upgrade)) { + $io->success('All users and group permissions schemas are up-to-date. No update needed.'); + + return 0; + } + + //List all users and groups that need an update + $io->section('Groups that need an update:'); + $io->listing(array_map(function (Group $group) { + return $group->getName() . ' (ID: '. $group->getID() .', Current version: ' . $group->getPermissions()->getSchemaVersion() . ')'; + }, $groups_to_upgrade)); + + $io->section('Users that need an update:'); + $io->listing(array_map(function (User $user) { + return $user->getUsername() . ' (ID: '. $user->getID() .', Current version: ' . $user->getPermissions()->getSchemaVersion() . ')'; + }, $users_to_upgrade)); + + if(!$io->confirm('Continue with the update?', false)) { + $io->warning('Update aborted.'); + return 0; + } + + //Update all users and groups + foreach ($groups_to_upgrade as $group) { + $io->writeln('Updating group '. $group->getName() .' (ID: '. $group->getID() .') to schema version '. PermissionData::CURRENT_SCHEMA_VERSION .'...', OutputInterface::VERBOSITY_VERBOSE); + $this->permissionSchemaUpdater->upgradeSchema($group); + } + foreach ($users_to_upgrade as $user) { + $io->writeln('Updating user '. $user->getUsername() .' (ID: '. $user->getID() .') to schema version '. PermissionData::CURRENT_SCHEMA_VERSION .'...', OutputInterface::VERBOSITY_VERBOSE); + $this->permissionSchemaUpdater->upgradeSchema($user); + } + + $this->eventCommentHelper->setMessage('Manual permissions schema update via CLI'); + + //Write changes to database + $this->em->flush(); + + $io->success('All users and groups have been updated to the latest permissions schema version.'); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/AdminPages/DeviceController.php b/src/Controller/AdminPages/ProjectAdminController.php similarity index 62% rename from src/Controller/AdminPages/DeviceController.php rename to src/Controller/AdminPages/ProjectAdminController.php index 7f98a218..982c5b6c 100644 --- a/src/Controller/AdminPages/DeviceController.php +++ b/src/Controller/AdminPages/ProjectAdminController.php @@ -22,10 +22,11 @@ declare(strict_types=1); namespace App\Controller\AdminPages; -use App\Entity\Attachments\DeviceAttachment; -use App\Entity\Devices\Device; -use App\Entity\Parameters\DeviceParameter; +use App\Entity\Attachments\ProjectAttachment; +use App\Entity\ProjectSystem\Project; +use App\Entity\Parameters\ProjectParameter; use App\Form\AdminPages\BaseEntityAdminForm; +use App\Form\AdminPages\ProjectAdminForm; use App\Services\ImportExportSystem\EntityExporter; use App\Services\ImportExportSystem\EntityImporter; use App\Services\Trees\StructuralElementRecursionHelper; @@ -36,46 +37,46 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; /** - * @Route("/device") + * @Route("/project") */ -class DeviceController extends BaseAdminController +class ProjectAdminController extends BaseAdminController { - protected $entity_class = Device::class; - protected $twig_template = 'AdminPages/DeviceAdmin.html.twig'; - protected $form_class = BaseEntityAdminForm::class; - protected $route_base = 'device'; - protected $attachment_class = DeviceAttachment::class; - protected $parameter_class = DeviceParameter::class; + protected $entity_class = Project::class; + protected $twig_template = 'AdminPages/ProjectAdmin.html.twig'; + protected $form_class = ProjectAdminForm::class; + protected $route_base = 'project'; + protected $attachment_class = ProjectAttachment::class; + protected $parameter_class = ProjectParameter::class; /** - * @Route("/{id}", name="device_delete", methods={"DELETE"}) + * @Route("/{id}", name="project_delete", methods={"DELETE"}) */ - public function delete(Request $request, Device $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse + public function delete(Request $request, Project $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse { return $this->_delete($request, $entity, $recursionHelper); } /** - * @Route("/{id}/edit/{timestamp}", requirements={"id"="\d+"}, name="device_edit") - * @Route("/{id}", requirements={"id"="\d+"}) + * @Route("/{id}/edit/{timestamp}", requirements={"id"="\d+"}, name="project_edit") + * @Route("/{id}/edit", requirements={"id"="\d+"}) */ - public function edit(Device $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response + public function edit(Project $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response { return $this->_edit($entity, $request, $em, $timestamp); } /** - * @Route("/new", name="device_new") + * @Route("/new", name="project_new") * @Route("/{id}/clone", name="device_clone") * @Route("/") */ - public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Device $entity = null): Response + public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Project $entity = null): Response { return $this->_new($request, $em, $importer, $entity); } /** - * @Route("/export", name="device_export_all") + * @Route("/export", name="project_export_all") */ public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response { @@ -83,9 +84,9 @@ class DeviceController extends BaseAdminController } /** - * @Route("/{id}/export", name="device_export") + * @Route("/{id}/export", name="project_export") */ - public function exportEntity(Device $entity, EntityExporter $exporter, Request $request): Response + public function exportEntity(Project $entity, EntityExporter $exporter, Request $request): Response { return $this->_exportEntity($entity, $exporter, $request); } diff --git a/src/Controller/GroupController.php b/src/Controller/GroupController.php index 69099dbd..63d32c7b 100644 --- a/src/Controller/GroupController.php +++ b/src/Controller/GroupController.php @@ -32,6 +32,7 @@ use App\Services\ImportExportSystem\EntityExporter; use App\Services\ImportExportSystem\EntityImporter; use App\Services\Trees\StructuralElementRecursionHelper; use App\Services\UserSystem\PermissionPresetsHelper; +use App\Services\UserSystem\PermissionSchemaUpdater; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -54,8 +55,11 @@ class GroupController extends BaseAdminController * @Route("/{id}/edit/{timestamp}", requirements={"id"="\d+"}, name="group_edit") * @Route("/{id}/", requirements={"id"="\d+"}) */ - public function edit(Group $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, ?string $timestamp = null): Response + public function edit(Group $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, PermissionSchemaUpdater $permissionSchemaUpdater, ?string $timestamp = null): Response { + //Do an upgrade of the permission schema if needed (so the user can see the permissions a user get on next request (even if it was not done yet) + $permissionSchemaUpdater->groupUpgradeSchemaRecursively($entity); + //Handle permissions presets if ($request->request->has('permission_preset')) { $this->denyAccessUnlessGranted('edit_permissions', $entity); diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b45f1fab..c2840314 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -31,6 +31,7 @@ use App\Entity\Parts\PartLot; use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Orderdetail; +use App\Entity\ProjectSystem\Project; use App\Exceptions\AttachmentDownloadException; use App\Form\Part\PartBaseType; use App\Services\Attachments\AttachmentSubmitHandler; @@ -39,16 +40,20 @@ use App\Services\LogSystem\EventCommentHelper; use App\Services\LogSystem\HistoryHelper; use App\Services\LogSystem\TimeTravel; use App\Services\Parameters\ParameterExtractor; +use App\Services\Parts\PartLotWithdrawAddHelper; use App\Services\Parts\PricedetailHelper; +use App\Services\ProjectSystem\ProjectBuildPartHelper; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; use Omines\DataTablesBundle\DataTableFactory; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Contracts\Translation\TranslatorInterface; @@ -76,7 +81,7 @@ class PartController extends AbstractController * @throws Exception */ public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper, - DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, ?string $timestamp = null): Response + DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response { $this->denyAccessUnlessGranted('read', $part); @@ -117,6 +122,7 @@ class PartController extends AbstractController 'timeTravel' => $timeTravel_timestamp, 'description_params' => $parameterExtractor->extractParameters($part->getDescription()), 'comment_params' => $parameterExtractor->extractParameters($part->getComment()), + 'withdraw_add_helper' => $withdrawAddHelper, ] ); } @@ -203,14 +209,26 @@ class PartController extends AbstractController /** * @Route("/new", name="part_new") * @Route("/{id}/clone", name="part_clone") + * @Route("/new_build_part/{project_id}", name="part_new_build_part") + * @ParamConverter("part", options={"id" = "id"}) + * @ParamConverter("project", options={"id" = "project_id"}) */ public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator, - AttachmentSubmitHandler $attachmentSubmitHandler, ?Part $part = null): Response + AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper, + ?Part $part = null, ?Project $project = null): Response { - if (null === $part) { - $new_part = new Part(); - } else { + + if ($part) { //Clone part $new_part = clone $part; + } else if ($project) { //Initialize a new part for a build part from the given project + //Ensure that the project has not already a build part + if ($project->getBuildPart() !== null) { + $this->addFlash('error', 'part.new_build_part.error.build_part_already_exists'); + return $this->redirectToRoute('part_edit', ['id' => $project->getBuildPart()->getID()]); + } + $new_part = $projectBuildPartHelper->getPartInitialization($project); + } else { //Create an empty part from scratch + $new_part = new Part(); } $this->denyAccessUnlessGranted('create', $new_part); @@ -280,6 +298,11 @@ class PartController extends AbstractController $em->flush(); $this->addFlash('success', 'part.created_flash'); + //If a redirect URL was given, redirect there + if ($request->query->get('_redirect')) { + return $this->redirect($request->query->get('_redirect')); + } + //Redirect to clone page if user wished that... //@phpstan-ignore-next-line if ('save_and_clone' === $form->getClickedButton()->getName()) { @@ -299,4 +322,66 @@ class PartController extends AbstractController 'form' => $form, ]); } + + /** + * @Route("/{id}/add_withdraw", name="part_add_withdraw", methods={"POST"}) + */ + public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response + { + if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) { + //Retrieve partlot from the request + $partLot = $em->find(PartLot::class, $request->request->get('lot_id')); + if($partLot === null) { + 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!"); + } + //Try to determine the target lot (used for move actions) + $targetLot = $em->find(PartLot::class, $request->request->get('target_id')); + if ($targetLot && $targetLot->getPart() !== $part) { + throw new \RuntimeException("The target partlot does not belong to the part!"); + } + + //Extract the amount and comment from the request + $amount = (float) $request->request->get('amount'); + $comment = $request->request->get('comment'); + $action = $request->request->get('action'); + + + + switch ($action) { + case "withdraw": + case "remove": + $this->denyAccessUnlessGranted('withdraw', $partLot); + $withdrawAddHelper->withdraw($partLot, $amount, $comment); + break; + case "add": + $this->denyAccessUnlessGranted('add', $partLot); + $withdrawAddHelper->add($partLot, $amount, $comment); + break; + case "move": + $this->denyAccessUnlessGranted('move', $partLot); + $withdrawAddHelper->move($partLot, $targetLot, $amount, $comment); + break; + default: + throw new \RuntimeException("Unknown action!"); + } + + //Save the changes to the DB + $em->flush(); + $this->addFlash('success', 'part.withdraw.success'); + + } else { + $this->addFlash('error', 'CSRF Token invalid!'); + } + + //If an 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()]); + } } diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 5d10da0b..2d2c4b3e 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -77,7 +77,7 @@ class PartListsController extends AbstractController $this->addFlash('error', 'part.table.actions.no_params_given'); } else { $parts = $actionHandler->idStringToArray($ids); - $actionHandler->handleAction($action, $parts, $target ? (int) $target : null); + $redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect); //Save changes $this->entityManager->flush(); @@ -85,6 +85,11 @@ class PartListsController extends AbstractController $this->addFlash('success', 'part.table.actions.success'); } + //If the action handler returned a response, we use it, otherwise we redirect back to the previous page. + if (isset($redirectResponse) && $redirectResponse instanceof Response) { + return $redirectResponse; + } + return $this->redirect($redirect); } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php new file mode 100644 index 00000000..f4e385cb --- /dev/null +++ b/src/Controller/ProjectController.php @@ -0,0 +1,149 @@ +. + */ + +namespace App\Controller; + +use App\DataTables\ProjectBomEntriesDataTable; +use App\Entity\Parts\Part; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Form\ProjectSystem\ProjectBOMEntryCollectionType; +use App\Form\Type\StructuralEntityType; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\EntityManagerInterface; +use Omines\DataTablesBundle\DataTableFactory; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Validator\Constraints\NotNull; + +/** + * @Route("/project") + */ +class ProjectController extends AbstractController +{ + private DataTableFactory $dataTableFactory; + + public function __construct(DataTableFactory $dataTableFactory) + { + $this->dataTableFactory = $dataTableFactory; + } + + /** + * @Route("/{id}/info", name="project_info", requirements={"id"="\d+"}) + */ + public function info(Project $project, Request $request) + { + $this->denyAccessUnlessGranted('read', $project); + + $table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project]) + ->handleRequest($request); + + if ($table->isCallback()) { + return $table->getResponse(); + } + + return $this->render('Projects/info/info.html.twig', [ + 'datatable' => $table, + 'project' => $project, + ]); + } + + /** + * @Route("/add_parts", name="project_add_parts_no_id") + * @Route("/{id}/add_parts", name="project_add_parts", requirements={"id"="\d+"}) + * @param Request $request + * @param Project|null $project + */ + public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response + { + if($project) { + $this->denyAccessUnlessGranted('edit', $project); + } else { + $this->denyAccessUnlessGranted('@projects.edit'); + } + + $builder = $this->createFormBuilder(); + $builder->add('project', StructuralEntityType::class, [ + 'class' => Project::class, + 'required' => true, + 'disabled' => $project !== null, //If a project is given, disable the field + 'data' => $project, + 'constraints' => [ + new NotNull() + ] + ]); + $builder->add('bom_entries', ProjectBOMEntryCollectionType::class); + $builder->add('submit', SubmitType::class, ['label' => 'save']); + $form = $builder->getForm(); + + //Preset the BOM entries with the selected parts, when the form was not submitted yet + $preset_data = new ArrayCollection(); + foreach (explode(',', $request->get('parts', '')) as $part_id) { + $part = $entityManager->getRepository(Part::class)->find($part_id); + if (null !== $part) { + //If there is already a BOM entry for this part, we use this one (we edit it then) + $bom_entry = $entityManager->getRepository(ProjectBOMEntry::class)->findOneBy([ + 'project' => $project, + 'part' => $part + ]); + if ($bom_entry) { + $preset_data->add($bom_entry); + } else { //Otherwise create an empty one + $entry = new ProjectBOMEntry(); + $entry->setProject($project); + $entry->setPart($part); + $preset_data->add($entry); + } + } + } + $form['bom_entries']->setData($preset_data); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $target_project = $project ?? $form->get('project')->getData(); + + //Ensure that we really have acces to the selected project + $this->denyAccessUnlessGranted('edit', $target_project); + + $data = $form->getData(); + $bom_entries = $data['bom_entries']; + foreach ($bom_entries as $bom_entry){ + $target_project->addBOMEntry($bom_entry); + } + $entityManager->flush(); + + //If a redirect query parameter is set, redirect to this page + if ($request->query->get('_redirect')) { + return $this->redirect($request->query->get('_redirect')); + } + //Otherwise just show the project info page + return $this->redirectToRoute('project_info', ['id' => $target_project->getID()]); + } + + return $this->renderForm('Projects/add_parts.html.twig', [ + 'project' => $project, + 'form' => $form, + ]); + } +} \ No newline at end of file diff --git a/src/Controller/SelectAPIController.php b/src/Controller/SelectAPIController.php index f26c11f3..04b4fadd 100644 --- a/src/Controller/SelectAPIController.php +++ b/src/Controller/SelectAPIController.php @@ -25,6 +25,7 @@ use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\ProjectSystem\Project; use App\Services\Trees\NodesListBuilder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -79,6 +80,14 @@ class SelectAPIController extends AbstractController return $this->getResponseForClass(MeasurementUnit::class, true); } + /** + * @Route("/project", name="select_project") + */ + public function projects(): Response + { + return $this->getResponseForClass(Project::class, false); + } + protected function getResponseForClass(string $class, bool $include_empty = false): Response { $test_obj = new $class(); diff --git a/src/Controller/TreeController.php b/src/Controller/TreeController.php index fbfd2c18..6ab3b420 100644 --- a/src/Controller/TreeController.php +++ b/src/Controller/TreeController.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace App\Controller; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -136,10 +136,10 @@ class TreeController extends AbstractController * @Route("/device/{id}", name="tree_device") * @Route("/devices", name="tree_device_root") */ - public function deviceTree(?Device $device = null): JsonResponse + public function deviceTree(?Project $device = null): JsonResponse { - if ($this->isGranted('@devices.read')) { - $tree = $this->treeGenerator->getTreeView(Device::class, $device, 'devices'); + if ($this->isGranted('@projects.read')) { + $tree = $this->treeGenerator->getTreeView(Project::class, $device, 'devices'); } else { return new JsonResponse("Access denied", 403); } diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 3a08d0d6..37ff6ec1 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -24,7 +24,7 @@ namespace App\Controller; use App\Entity\Parameters\AttachmentTypeParameter; use App\Entity\Parameters\CategoryParameter; -use App\Entity\Parameters\DeviceParameter; +use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\FootprintParameter; use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; @@ -32,10 +32,12 @@ use App\Entity\Parameters\MeasurementUnitParameter; use App\Entity\Parameters\PartParameter; use App\Entity\Parameters\StorelocationParameter; use App\Entity\Parameters\SupplierParameter; +use App\Entity\Parts\Part; use App\Entity\PriceInformations\Currency; use App\Repository\ParameterRepository; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; +use App\Services\Attachments\PartPreviewGenerator; use App\Services\Tools\TagFinder; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -105,7 +107,7 @@ class TypeaheadController extends AbstractController case 'part': return PartParameter::class; case 'device': - return DeviceParameter::class; + return ProjectParameter::class; case 'footprint': return FootprintParameter::class; case 'manufacturer': @@ -128,6 +130,45 @@ class TypeaheadController extends AbstractController } } + /** + * @Route("/parts/search/{query}", name="typeahead_parts") + * @param string $query + * @param EntityManagerInterface $entityManager + * @return JsonResponse + */ + public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator, + AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse + { + $this->denyAccessUnlessGranted('@parts.read'); + + $repo = $entityManager->getRepository(Part::class); + + $parts = $repo->autocompleteSearch($query, 100); + + $data = []; + foreach ($parts as $part) { + //Determine the picture to show: + $preview_attachment = $previewGenerator->getTablePreviewAttachment($part); + if($preview_attachment !== null) { + $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); + } else { + $preview_url = ''; + } + + /** @var Part $part */ + $data[] = [ + 'id' => $part->getID(), + 'name' => $part->getName(), + 'category' => $part->getCategory() ? $part->getCategory()->getName() : 'Unknown', + 'footprint' => $part->getFootprint() ? $part->getFootprint()->getName() : '', + 'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), + 'image' => $preview_url, + ]; + } + + return new JsonResponse($data); + } + /** * @Route("/parameters/{type}/search/{query}", name="typeahead_parameters", requirements={"type" = ".+"}) * @param string $query diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index c72ea7fe..826510b5 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -35,6 +35,7 @@ use App\Services\ImportExportSystem\EntityExporter; use App\Services\ImportExportSystem\EntityImporter; use App\Services\Trees\StructuralElementRecursionHelper; use App\Services\UserSystem\PermissionPresetsHelper; +use App\Services\UserSystem\PermissionSchemaUpdater; use Doctrine\ORM\EntityManagerInterface; use Exception; use InvalidArgumentException; @@ -82,10 +83,12 @@ class UserController extends AdminPages\BaseAdminController * * @throws Exception */ - public function edit(User $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, ?string $timestamp = null): Response + public function edit(User $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, PermissionSchemaUpdater $permissionSchemaUpdater, ?string $timestamp = null): Response { - //Handle 2FA disabling + //Do an upgrade of the permission schema if needed (so the user can see the permissions a user get on next request (even if it was not done yet) + $permissionSchemaUpdater->userUpgradeSchemaRecursively($entity); + //Handle 2FA disabling if ($request->request->has('reset_2fa')) { //Check if the admin has the needed permissions $this->denyAccessUnlessGranted('set_password', $entity); diff --git a/src/DataFixtures/DataStructureFixtures.php b/src/DataFixtures/DataStructureFixtures.php index 5697b156..c7416abe 100644 --- a/src/DataFixtures/DataStructureFixtures.php +++ b/src/DataFixtures/DataStructureFixtures.php @@ -24,7 +24,7 @@ namespace App\DataFixtures; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractStructuralDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -51,7 +51,7 @@ class DataStructureFixtures extends Fixture public function load(ObjectManager $manager): void { //Reset autoincrement - $types = [AttachmentType::class, Device::class, Category::class, Footprint::class, Manufacturer::class, + $types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class, MeasurementUnit::class, Storelocation::class, Supplier::class, ]; foreach ($types as $type) { diff --git a/src/DataFixtures/GroupFixtures.php b/src/DataFixtures/GroupFixtures.php index 3a93d14e..93e93b79 100644 --- a/src/DataFixtures/GroupFixtures.php +++ b/src/DataFixtures/GroupFixtures.php @@ -72,7 +72,7 @@ class GroupFixtures extends Fixture private function addDevicesPermissions(Group $group): void { - $this->permissionManager->setAllOperationsOfPermission($group, 'devices', true); + $this->permissionManager->setAllOperationsOfPermission($group, 'projects', true); } } diff --git a/src/DataTables/Column/EntityColumn.php b/src/DataTables/Column/EntityColumn.php index 715e39c4..d48d6da1 100644 --- a/src/DataTables/Column/EntityColumn.php +++ b/src/DataTables/Column/EntityColumn.php @@ -65,16 +65,21 @@ class EntityColumn extends AbstractColumn }); $resolver->setDefault('render', function (Options $options) { - return function ($value, Part $context) use ($options) { - /** @var AbstractDBElement|null $entity */ - $entity = $this->accessor->getValue($context, $options['property']); + return function ($value, $context) use ($options) { + if ($this->accessor->isReadable($context, $options['property'])) { + $entity = $this->accessor->getValue($context, $options['property']); + } else { + $entity = null; + } + + /** @var AbstractNamedDBElement|null $entity */ if (null !== $entity) { if (null !== $entity->getID()) { return sprintf( '%s', $this->urlGenerator->listPartsURL($entity), - $value + $entity->getName() ); } diff --git a/src/DataTables/Column/LogEntryTargetColumn.php b/src/DataTables/Column/LogEntryTargetColumn.php index 4c8ad386..4aaeb069 100644 --- a/src/DataTables/Column/LogEntryTargetColumn.php +++ b/src/DataTables/Column/LogEntryTargetColumn.php @@ -31,6 +31,7 @@ use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\PartLot; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; +use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Exceptions\EntityNotSupportedException; use App\Repository\LogEntryRepository; use App\Services\ElementTypeNameGenerator; @@ -126,6 +127,8 @@ class LogEntryTargetColumn extends AbstractColumn $on = $target->getPart(); } elseif ($target instanceof Pricedetail && null !== $target->getOrderdetail() && null !== $target->getOrderdetail()->getPart()) { $on = $target->getOrderdetail()->getPart(); + } elseif ($target instanceof ProjectBOMEntry && null !== $target->getProject()) { + $on = $target->getProject(); } if (isset($on) && is_object($on)) { diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php new file mode 100644 index 00000000..90255835 --- /dev/null +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -0,0 +1,95 @@ +. + */ + +namespace App\DataTables\Helpers; + +use App\Entity\Parts\Part; +use App\Services\Attachments\AttachmentURLGenerator; +use App\Services\Attachments\PartPreviewGenerator; +use App\Services\EntityURLGenerator; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * A helper service which contains common code to render columns for part related tables + */ +class PartDataTableHelper +{ + private PartPreviewGenerator $previewGenerator; + private AttachmentURLGenerator $attachmentURLGenerator; + + private TranslatorInterface $translator; + private EntityURLGenerator $entityURLGenerator; + + public function __construct(PartPreviewGenerator $previewGenerator, AttachmentURLGenerator $attachmentURLGenerator, + EntityURLGenerator $entityURLGenerator, TranslatorInterface $translator) + { + $this->previewGenerator = $previewGenerator; + $this->attachmentURLGenerator = $attachmentURLGenerator; + $this->translator = $translator; + $this->entityURLGenerator = $entityURLGenerator; + } + + public function renderName(Part $context): string + { + $icon = ''; + + //Depending on the part status we show a different icon (the later conditions have higher priority) + if ($context->isFavorite()) { + $icon = sprintf('', $this->translator->trans('part.favorite.badge')); + } + if ($context->isNeedsReview()) { + $icon = sprintf('', $this->translator->trans('part.needs_review.badge')); + } + if ($context->getBuiltProject() !== null) { + $icon = sprintf('', + $this->translator->trans('part.info.projectBuildPart.hint') . ': ' . $context->getBuiltProject()->getName()); + } + + + return sprintf( + '%s%s', + $this->entityURLGenerator->infoURL($context), + $icon, + htmlentities($context->getName()) + ); + } + + public function renderPicture(Part $context): string + { + $preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context); + if (null === $preview_attachment) { + return ''; + } + + $title = htmlspecialchars($preview_attachment->getName()); + if ($preview_attachment->getFilename()) { + $title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')'; + } + + return sprintf( + '%s', + 'Part image', + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment), + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'), + 'img-fluid hoverpic', + $title + ); + } +} \ No newline at end of file diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php index 35e24f0d..493cc91c 100644 --- a/src/DataTables/LogDataTable.php +++ b/src/DataTables/LogDataTable.php @@ -37,6 +37,7 @@ use App\Entity\LogSystem\CollectionElementDeleted; use App\Entity\LogSystem\ElementCreatedLogEntry; use App\Entity\LogSystem\ElementDeletedLogEntry; use App\Entity\LogSystem\ElementEditedLogEntry; +use App\Entity\LogSystem\PartStockChangedLogEntry; use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; use App\Exceptions\EntityNotSupportedException; @@ -190,7 +191,16 @@ class LogDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('log.type'), 'propertyPath' => 'type', 'render' => function (string $value, AbstractLogEntry $context) { - return $this->translator->trans('log.type.'.$value); + $text = $this->translator->trans('log.type.'.$value); + + if ($context instanceof PartStockChangedLogEntry) { + $text .= sprintf( + ' (%s)', + $this->translator->trans('log.part_stock_changed.' . $context->getInstockChangeType()) + ); + } + + return $text; }, ]); diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index e77cd081..f38215e3 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -34,6 +34,7 @@ use App\DataTables\Column\SIUnitNumberColumn; use App\DataTables\Column\TagsColumn; use App\DataTables\Filters\PartFilter; use App\DataTables\Filters\PartSearchFilter; +use App\DataTables\Helpers\PartDataTableHelper; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -63,26 +64,27 @@ final class PartsDataTable implements DataTableTypeInterface private TranslatorInterface $translator; private NodesListBuilder $treeBuilder; private AmountFormatter $amountFormatter; - private PartPreviewGenerator $previewGenerator; private AttachmentURLGenerator $attachmentURLGenerator; private Security $security; + private PartDataTableHelper $partDataTableHelper; + /** * @var EntityURLGenerator */ private $urlGenerator; public function __construct(EntityURLGenerator $urlGenerator, TranslatorInterface $translator, - NodesListBuilder $treeBuilder, AmountFormatter $amountFormatter, - PartPreviewGenerator $previewGenerator, AttachmentURLGenerator $attachmentURLGenerator, Security $security) + NodesListBuilder $treeBuilder, AmountFormatter $amountFormatter,PartDataTableHelper $partDataTableHelper, + AttachmentURLGenerator $attachmentURLGenerator, Security $security) { $this->urlGenerator = $urlGenerator; $this->translator = $translator; $this->treeBuilder = $treeBuilder; $this->amountFormatter = $amountFormatter; - $this->previewGenerator = $previewGenerator; $this->attachmentURLGenerator = $attachmentURLGenerator; $this->security = $security; + $this->partDataTableHelper = $partDataTableHelper; } public function configureOptions(OptionsResolver $optionsResolver): void @@ -122,46 +124,13 @@ final class PartsDataTable implements DataTableTypeInterface 'label' => '', 'className' => 'no-colvis', 'render' => function ($value, Part $context) { - $preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context); - if (null === $preview_attachment) { - return ''; - } - - $title = htmlspecialchars($preview_attachment->getName()); - if ($preview_attachment->getFilename()) { - $title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')'; - } - - return sprintf( - '%s', - 'Part image', - $this->attachmentURLGenerator->getThumbnailURL($preview_attachment), - $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'), - 'img-fluid hoverpic', - $title - ); + return $this->partDataTableHelper->renderPicture($context); }, ]) ->add('name', TextColumn::class, [ 'label' => $this->translator->trans('part.table.name'), 'render' => function ($value, Part $context) { - $icon = ''; - - //Depending on the part status we show a different icon (the later conditions have higher priority) - if ($context->isFavorite()) { - $icon = sprintf('', $this->translator->trans('part.favorite.badge')); - } - if ($context->isNeedsReview()) { - $icon = sprintf('', $this->translator->trans('part.needs_review.badge')); - } - - - return sprintf( - '%s%s', - $this->urlGenerator->infoURL($context), - $icon, - htmlentities($context->getName()) - ); + return $this->partDataTableHelper->renderName($context); }, ]) ->add('id', TextColumn::class, [ diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php new file mode 100644 index 00000000..7fabff8b --- /dev/null +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -0,0 +1,186 @@ +. + */ + +namespace App\DataTables; + +use App\DataTables\Column\EntityColumn; +use App\DataTables\Column\LocaleDateTimeColumn; +use App\DataTables\Column\MarkdownColumn; +use App\DataTables\Column\SelectColumn; +use App\DataTables\Helpers\PartDataTableHelper; +use App\Entity\Attachments\Attachment; +use App\Entity\Parts\Part; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Services\EntityURLGenerator; +use App\Services\Formatters\AmountFormatter; +use Doctrine\ORM\QueryBuilder; +use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; +use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; +use Omines\DataTablesBundle\Column\TextColumn; +use Omines\DataTablesBundle\DataTable; +use Omines\DataTablesBundle\DataTableTypeInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +class ProjectBomEntriesDataTable implements DataTableTypeInterface +{ + protected TranslatorInterface $translator; + protected PartDataTableHelper $partDataTableHelper; + protected EntityURLGenerator $entityURLGenerator; + protected AmountFormatter $amountFormatter; + + public function __construct(TranslatorInterface $translator, PartDataTableHelper $partDataTableHelper, + EntityURLGenerator $entityURLGenerator, AmountFormatter $amountFormatter) + { + $this->translator = $translator; + $this->partDataTableHelper = $partDataTableHelper; + $this->entityURLGenerator = $entityURLGenerator; + $this->amountFormatter = $amountFormatter; + } + + + public function configure(DataTable $dataTable, array $options) + { + $dataTable + //->add('select', SelectColumn::class) + ->add('picture', TextColumn::class, [ + 'label' => '', + 'className' => 'no-colvis', + 'render' => function ($value, ProjectBOMEntry $context) { + if($context->getPart() === null) { + return ''; + } + return $this->partDataTableHelper->renderPicture($context->getPart()); + }, + ]) + + ->add('id', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.id'), + 'visible' => false, + ]) + + ->add('quantity', TextColumn::class, [ + 'label' => $this->translator->trans('project.bom.quantity'), + 'className' => 'text-center', + 'render' => function ($value, ProjectBOMEntry $context) { + //If we have a non-part entry, only show the rounded quantity + if ($context->getPart() === null) { + return round($context->getQuantity()); + } + //Otherwise use the unit of the part to format the quantity + return $this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()); + }, + ]) + + ->add('name', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.name'), + 'orderable' => false, + 'render' => function ($value, ProjectBOMEntry $context) { + if($context->getPart() === null) { + return $context->getName(); + } + if($context->getPart() !== null) { + $tmp = $this->partDataTableHelper->renderName($context->getPart()); + if(!empty($context->getName())) { + $tmp .= '
'.htmlspecialchars($context->getName()).''; + } + return $tmp; + } + }, + ]) + + ->add('description', MarkdownColumn::class, [ + 'label' => $this->translator->trans('part.table.description'), + 'data' => function (ProjectBOMEntry $context) { + if($context->getPart() !== null) { + return $context->getPart()->getDescription(); + } + //For non-part BOM entries show the comment field + return $context->getComment(); + }, + ]) + + + ->add('category', EntityColumn::class, [ + 'label' => $this->translator->trans('part.table.category'), + 'property' => 'part.category', + ]) + ->add('footprint', EntityColumn::class, [ + 'property' => 'part.footprint', + 'label' => $this->translator->trans('part.table.footprint'), + ]) + + ->add('manufacturer', EntityColumn::class, [ + 'property' => 'part.manufacturer', + 'label' => $this->translator->trans('part.table.manufacturer'), + ]) + + ->add('mountnames', TextColumn::class, [ + 'label' => 'project.bom.mountnames', + 'render' => function ($value, ProjectBOMEntry $context) { + $html = ''; + + foreach (explode(',', $context->getMountnames()) as $mountname) { + $html .= sprintf('%s ', htmlspecialchars($mountname)); + } + return $html; + }, + ]) + + + ->add('addedDate', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('part.table.addedDate'), + 'visible' => false, + ]) + ->add('lastModified', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('part.table.lastModified'), + 'visible' => false, + ]) + ; + + $dataTable->createAdapter(ORMAdapter::class, [ + 'entity' => Attachment::class, + 'query' => function (QueryBuilder $builder) use ($options): void { + $this->getQuery($builder, $options); + }, + 'criteria' => [ + function (QueryBuilder $builder) use ($options): void { + $this->buildCriteria($builder, $options); + }, + new SearchCriteriaProvider(), + ], + ]); + } + + private function getQuery(QueryBuilder $builder, array $options): void + { + $builder->select('bom_entry') + ->addSelect('part') + ->from(ProjectBOMEntry::class, 'bom_entry') + ->leftJoin('bom_entry.part', 'part') + ->where('bom_entry.project = :project') + ->setParameter('project', $options['project']); + ; + } + + private function buildCriteria(QueryBuilder $builder, array $options): void + { + + } +} \ No newline at end of file diff --git a/src/Helpers/BigDecimalType.php b/src/Doctrine/Types/BigDecimalType.php similarity index 98% rename from src/Helpers/BigDecimalType.php rename to src/Doctrine/Types/BigDecimalType.php index 0471fb5d..f1522857 100644 --- a/src/Helpers/BigDecimalType.php +++ b/src/Doctrine/Types/BigDecimalType.php @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -namespace App\Helpers; +namespace App\Doctrine\Types; use Brick\Math\BigDecimal; use Brick\Math\BigNumber; diff --git a/src/Helpers/UTCDateTimeType.php b/src/Doctrine/Types/UTCDateTimeType.php similarity index 98% rename from src/Helpers/UTCDateTimeType.php rename to src/Doctrine/Types/UTCDateTimeType.php index 9109ec8a..0dd7a461 100644 --- a/src/Helpers/UTCDateTimeType.php +++ b/src/Doctrine/Types/UTCDateTimeType.php @@ -20,7 +20,7 @@ declare(strict_types=1); -namespace App\Helpers; +namespace App\Doctrine\Types; use DateTime; use DateTimeZone; diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index 0f603c09..a4807a21 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -44,7 +44,7 @@ use LogicException; * @ORM\DiscriminatorColumn(name="class_name", type="string") * @ORM\DiscriminatorMap({ * "PartDB\Part" = "PartAttachment", "Part" = "PartAttachment", - * "PartDB\Device" = "DeviceAttachment", "Device" = "DeviceAttachment", + * "PartDB\Device" = "ProjectAttachment", "Device" = "ProjectAttachment", * "AttachmentType" = "AttachmentTypeAttachment", "Category" = "CategoryAttachment", * "Footprint" = "FootprintAttachment", "Manufacturer" = "ManufacturerAttachment", * "Currency" = "CurrencyAttachment", "Group" = "GroupAttachment", diff --git a/src/Entity/Attachments/DeviceAttachment.php b/src/Entity/Attachments/ProjectAttachment.php similarity index 81% rename from src/Entity/Attachments/DeviceAttachment.php rename to src/Entity/Attachments/ProjectAttachment.php index 4f394789..1fab6bc8 100644 --- a/src/Entity/Attachments/DeviceAttachment.php +++ b/src/Entity/Attachments/ProjectAttachment.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace App\Entity\Attachments; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; @@ -32,12 +32,12 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; * @ORM\Entity() * @UniqueEntity({"name", "attachment_type", "element"}) */ -class DeviceAttachment extends Attachment +class ProjectAttachment extends Attachment { - public const ALLOWED_ELEMENT_CLASS = Device::class; + public const ALLOWED_ELEMENT_CLASS = Project::class; /** - * @var Device the element this attachment is associated with - * @ORM\ManyToOne(targetEntity="App\Entity\Devices\Device", inversedBy="attachments") + * @var Project the element this attachment is associated with + * @ORM\ManyToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="attachments") * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE"). */ protected ?AttachmentContainingDBElement $element = null; diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php index 9da911a3..dd736eac 100644 --- a/src/Entity/Base/AbstractDBElement.php +++ b/src/Entity/Base/AbstractDBElement.php @@ -41,8 +41,8 @@ use Symfony\Component\Serializer\Annotation\Groups; * "attachment_type" = "App\Entity\AttachmentType", * "attachment" = "App\Entity\Attachment", * "category" = "App\Entity\Attachment", - * "device" = "App\Entity\Device", - * "device_part" = "App\Entity\DevicePart", + * "project" = "App\Entity\ProjectSystem\Project", + * "project_bom_entry" = "App\Entity\ProjectSystem\ProjectBOMEntry", * "footprint" = "App\Entity\Footprint", * "group" = "App\Entity\Group", * "manufacturer" = "App\Entity\Manufacturer", diff --git a/src/Entity/Base/AbstractPartsContainingDBElement.php b/src/Entity/Base/AbstractPartsContainingDBElement.php index a8ba8e8c..f30819f5 100644 --- a/src/Entity/Base/AbstractPartsContainingDBElement.php +++ b/src/Entity/Base/AbstractPartsContainingDBElement.php @@ -29,6 +29,7 @@ use Doctrine\ORM\Mapping as ORM; * * @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository") */ -abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement +abstract class +AbstractPartsContainingDBElement extends AbstractStructuralDBElement { } diff --git a/src/Entity/Devices/Device.php b/src/Entity/Devices/Device.php deleted file mode 100644 index 15fee00a..00000000 --- a/src/Entity/Devices/Device.php +++ /dev/null @@ -1,142 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Entity\Devices; - -use App\Entity\Attachments\DeviceAttachment; -use App\Entity\Base\AbstractPartsContainingDBElement; -use App\Entity\Parameters\DeviceParameter; -use Doctrine\Common\Collections\Collection; -use Doctrine\ORM\Mapping as ORM; -use InvalidArgumentException; - -/** - * Class AttachmentType. - * - * @ORM\Entity(repositoryClass="App\Repository\Parts\DeviceRepository") - * @ORM\Table(name="`devices`") - */ -class Device extends AbstractPartsContainingDBElement -{ - /** - * @ORM\OneToMany(targetEntity="Device", mappedBy="parent") - * @ORM\OrderBy({"name" = "ASC"}) - * @var Collection - */ - protected $children; - - /** - * @ORM\ManyToOne(targetEntity="Device", inversedBy="children") - * @ORM\JoinColumn(name="parent_id", referencedColumnName="id") - */ - protected $parent; - - /** - * @ORM\OneToMany(targetEntity="DevicePart", mappedBy="device") - */ - protected $parts; - - /** - * @ORM\Column(type="integer") - */ - protected int $order_quantity = 0; - - /** - * @ORM\Column(type="boolean") - */ - protected bool $order_only_missing_parts = false; - /** - * @var Collection - * @ORM\OneToMany(targetEntity="App\Entity\Attachments\DeviceAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) - * @ORM\OrderBy({"name" = "ASC"}) - */ - protected $attachments; - - /** @var Collection - * @ORM\OneToMany(targetEntity="App\Entity\Parameters\DeviceParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) - * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"}) - */ - protected $parameters; - - /******************************************************************************** - * - * Getters - * - *********************************************************************************/ - - /** - * Get the order quantity of this device. - * - * @return int the order quantity - */ - public function getOrderQuantity(): int - { - return $this->order_quantity; - } - - /** - * Get the "order_only_missing_parts" attribute. - * - * @return bool the "order_only_missing_parts" attribute - */ - public function getOrderOnlyMissingParts(): bool - { - return $this->order_only_missing_parts; - } - - /******************************************************************************** - * - * Setters - * - *********************************************************************************/ - - /** - * Set the order quantity. - * - * @param int $new_order_quantity the new order quantity - * - * @return $this - */ - public function setOrderQuantity(int $new_order_quantity): self - { - if ($new_order_quantity < 0) { - throw new InvalidArgumentException('The new order quantity must not be negative!'); - } - $this->order_quantity = $new_order_quantity; - - return $this; - } - - /** - * Set the "order_only_missing_parts" attribute. - * - * @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute - * - * @return Device - */ - public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self - { - $this->order_only_missing_parts = $new_order_only_missing_parts; - - return $this; - } -} diff --git a/src/Entity/Devices/DevicePart.php b/src/Entity/Devices/DevicePart.php deleted file mode 100644 index ed627a4c..00000000 --- a/src/Entity/Devices/DevicePart.php +++ /dev/null @@ -1,61 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Entity\Devices; - -use App\Entity\Base\AbstractDBElement; -use App\Entity\Parts\Part; -use Doctrine\ORM\Mapping as ORM; - -/** - * Class DevicePart. - * - * @ORM\Table("`device_parts`") - * @ORM\Entity() - */ -class DevicePart extends AbstractDBElement -{ - /** - * @var int - * @ORM\Column(type="integer", name="quantity") - */ - protected int $quantity; - - /** - * @var string - * @ORM\Column(type="text", name="mountnames") - */ - protected string $mountnames; - /** - * @var Device - * @ORM\ManyToOne(targetEntity="Device", inversedBy="parts") - * @ORM\JoinColumn(name="id_device", referencedColumnName="id") - */ - protected ?Device $device = null; - - /** - * @var Part - * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part") - * @ORM\JoinColumn(name="id_part", referencedColumnName="id") - */ - protected ?Part $part = null; -} diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php index 840d7a31..e2dca513 100644 --- a/src/Entity/LogSystem/AbstractLogEntry.php +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -25,8 +25,8 @@ namespace App\Entity\LogSystem; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractDBElement; -use App\Entity\Devices\Device; -use App\Entity\Devices\DevicePart; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -67,10 +67,11 @@ use Psr\Log\LogLevel; * 6 = "ElementCreatedLogEntry", * 7 = "ElementEditedLogEntry", * 8 = "ConfigChangedLogEntry", - * 9 = "InstockChangedLogEntry", + * 9 = "LegacyInstockChangedLogEntry", * 10 = "DatabaseUpdatedLogEntry", * 11 = "CollectionElementDeleted", * 12 = "SecurityEventLogEntry", + * 13 = "PartStockChangedLogEntry", * }) */ abstract class AbstractLogEntry extends AbstractDBElement @@ -124,8 +125,8 @@ abstract class AbstractLogEntry extends AbstractDBElement self::TARGET_TYPE_ATTACHEMENT => Attachment::class, self::TARGET_TYPE_ATTACHEMENTTYPE => AttachmentType::class, self::TARGET_TYPE_CATEGORY => Category::class, - self::TARGET_TYPE_DEVICE => Device::class, - self::TARGET_TYPE_DEVICEPART => DevicePart::class, + self::TARGET_TYPE_DEVICE => Project::class, + self::TARGET_TYPE_DEVICEPART => ProjectBOMEntry::class, self::TARGET_TYPE_FOOTPRINT => Footprint::class, self::TARGET_TYPE_GROUP => Group::class, self::TARGET_TYPE_MANUFACTURER => Manufacturer::class, diff --git a/src/Entity/LogSystem/CollectionElementDeleted.php b/src/Entity/LogSystem/CollectionElementDeleted.php index 0e8c06a0..5b12119a 100644 --- a/src/Entity/LogSystem/CollectionElementDeleted.php +++ b/src/Entity/LogSystem/CollectionElementDeleted.php @@ -46,7 +46,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentTypeAttachment; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; -use App\Entity\Attachments\DeviceAttachment; +use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; use App\Entity\Attachments\ManufacturerAttachment; @@ -58,12 +58,12 @@ use App\Entity\Attachments\UserAttachment; use App\Entity\Base\AbstractDBElement; use App\Entity\Contracts\LogWithEventUndoInterface; use App\Entity\Contracts\NamedElementInterface; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AttachmentTypeParameter; use App\Entity\Parameters\CategoryParameter; use App\Entity\Parameters\CurrencyParameter; -use App\Entity\Parameters\DeviceParameter; +use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\FootprintParameter; use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; @@ -159,8 +159,8 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU return CategoryParameter::class; case Currency::class: return CurrencyParameter::class; - case Device::class: - return DeviceParameter::class; + case Project::class: + return ProjectParameter::class; case Footprint::class: return FootprintParameter::class; case Group::class: @@ -189,8 +189,8 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU return CategoryAttachment::class; case Currency::class: return CurrencyAttachment::class; - case Device::class: - return DeviceAttachment::class; + case Project::class: + return ProjectAttachment::class; case Footprint::class: return FootprintAttachment::class; case Group::class: diff --git a/src/Entity/LogSystem/InstockChangedLogEntry.php b/src/Entity/LogSystem/LegacyInstockChangedLogEntry.php similarity index 97% rename from src/Entity/LogSystem/InstockChangedLogEntry.php rename to src/Entity/LogSystem/LegacyInstockChangedLogEntry.php index c4de7469..35d58592 100644 --- a/src/Entity/LogSystem/InstockChangedLogEntry.php +++ b/src/Entity/LogSystem/LegacyInstockChangedLogEntry.php @@ -27,7 +27,7 @@ use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() */ -class InstockChangedLogEntry extends AbstractLogEntry +class LegacyInstockChangedLogEntry extends AbstractLogEntry { protected string $typeString = 'instock_changed'; diff --git a/src/Entity/LogSystem/PartStockChangedLogEntry.php b/src/Entity/LogSystem/PartStockChangedLogEntry.php new file mode 100644 index 00000000..44852076 --- /dev/null +++ b/src/Entity/LogSystem/PartStockChangedLogEntry.php @@ -0,0 +1,224 @@ +. + */ + +namespace App\Entity\LogSystem; + +use App\Entity\Parts\PartLot; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class PartStockChangedLogEntry extends AbstractLogEntry +{ + public const TYPE_ADD = "add"; + public const TYPE_WITHDRAW = "withdraw"; + public const TYPE_MOVE = "move"; + + protected string $typeString = 'part_stock_changed'; + + protected const COMMENT_MAX_LENGTH = 300; + + /** + * Creates a new part stock changed log entry. + * @param string $type The type of the log entry. One of the TYPE_* constants. + * @param PartLot $lot The part lot which has been changed. + * @param float $old_stock The old stock of the lot. + * @param float $new_stock The new stock of the lot. + * @param float $new_total_part_instock The new total instock of the part. + * @param string $comment The comment associated with the change. + * @param PartLot|null $move_to_target The target lot if the type is TYPE_MOVE. + */ + protected function __construct(string $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null) + { + parent::__construct(); + + if (!in_array($type, [self::TYPE_ADD, self::TYPE_WITHDRAW, self::TYPE_MOVE], true)) { + throw new \InvalidArgumentException('Invalid type for PartStockChangedLogEntry!'); + } + + //Same as every other element change log entry + $this->level = self::LEVEL_INFO; + + $this->setTargetElement($lot); + + $this->typeString = 'part_stock_changed'; + $this->extra = array_merge($this->extra, [ + 't' => $this->typeToShortType($type), + 'o' => $old_stock, + 'n' => $new_stock, + 'p' => $new_total_part_instock, + ]); + if (!empty($comment)) { + $this->extra['c'] = mb_strimwidth($comment, 0, self::COMMENT_MAX_LENGTH, '...'); + } + + if ($move_to_target) { + if ($type !== self::TYPE_MOVE) { + throw new \InvalidArgumentException('The move_to_target parameter can only be set if the type is "move"!'); + } + + $this->extra['m'] = $move_to_target->getID(); + } + } + + /** + * Creates a new log entry for adding stock to a lot. + * @param PartLot $lot The part lot which has been changed. + * @param float $old_stock The old stock of the lot. + * @param float $new_stock The new stock of the lot. + * @param float $new_total_part_instock The new total instock of the part. + * @param string $comment The comment associated with the change. + * @return static + */ + public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self + { + return new self(self::TYPE_ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment); + } + + /** + * Creates a new log entry for withdrawing stock from a lot. + * @param PartLot $lot The part lot which has been changed. + * @param float $old_stock The old stock of the lot. + * @param float $new_stock The new stock of the lot. + * @param float $new_total_part_instock The new total instock of the part. + * @param string $comment The comment associated with the change. + * @return static + */ + public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self + { + return new self(self::TYPE_WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment); + } + + /** + * Creates a new log entry for moving stock from a lot to another lot. + * @param PartLot $lot The part lot which has been changed. + * @param float $old_stock The old stock of the lot. + * @param float $new_stock The new stock of the lot. + * @param float $new_total_part_instock The new total instock of the part. + * @param string $comment The comment associated with the change. + * @param PartLot $move_to_target The target lot. + */ + public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target): self + { + return new self(self::TYPE_MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target); + } + + /** + * Returns the instock change type of this entry + * @return string One of the TYPE_* constants. + */ + public function getInstockChangeType(): string + { + return $this->shortTypeToType($this->extra['t']); + } + + /** + * Returns the old stock of the lot. + * @return float + */ + public function getOldStock(): float + { + return $this->extra['o']; + } + + /** + * Returns the new stock of the lot. + * @return float + */ + public function getNewStock(): float + { + return $this->extra['n']; + } + + /** + * Returns the new total instock of the part. + * @return float + */ + public function getNewTotalPartInstock(): float + { + return $this->extra['p']; + } + + /** + * Returns the comment associated with the change. + * @return string + */ + public function getComment(): string + { + return $this->extra['c'] ?? ''; + } + + /** + * Gets the difference between the old and the new stock value of the lot as a positive number. + * @return float + */ + public function getChangeAmount(): float + { + return abs($this->getNewStock() - $this->getOldStock()); + } + + /** + * Returns the target lot ID (where the instock was moved to) if the type is TYPE_MOVE. + * @return int|null + */ + public function getMoveToTargetID(): ?int + { + return $this->extra['m'] ?? null; + } + + /** + * Converts the human-readable type (TYPE_* consts) to the version stored in DB + * @param string $type + * @return string + */ + protected function typeToShortType(string $type): string + { + switch ($type) { + case self::TYPE_ADD: + return 'a'; + case self::TYPE_WITHDRAW: + return 'w'; + case self::TYPE_MOVE: + return 'm'; + default: + throw new \InvalidArgumentException('Invalid type: '.$type); + } + } + + /** + * Converts the short type stored in DB to the human-readable type (TYPE_* consts). + * @param string $short_type + * @return string + */ + protected function shortTypeToType(string $short_type): string + { + switch ($short_type) { + case 'a': + return self::TYPE_ADD; + case 'w': + return self::TYPE_WITHDRAW; + case 'm': + return self::TYPE_MOVE; + default: + throw new \InvalidArgumentException('Invalid short type: '.$short_type); + } + } +} \ No newline at end of file diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index 55ebd9e5..5a3f00e3 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -62,7 +62,7 @@ use function sprintf; * @ORM\DiscriminatorMap({ * 0 = "CategoryParameter", * 1 = "CurrencyParameter", - * 2 = "DeviceParameter", + * 2 = "ProjectParameter", * 3 = "FootprintParameter", * 4 = "GroupParameter", * 5 = "ManufacturerParameter", diff --git a/src/Entity/Parameters/DeviceParameter.php b/src/Entity/Parameters/ProjectParameter.php similarity index 87% rename from src/Entity/Parameters/DeviceParameter.php rename to src/Entity/Parameters/ProjectParameter.php index d52e9037..2961a843 100644 --- a/src/Entity/Parameters/DeviceParameter.php +++ b/src/Entity/Parameters/ProjectParameter.php @@ -41,7 +41,7 @@ declare(strict_types=1); namespace App\Entity\Parameters; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; @@ -49,13 +49,13 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ -class DeviceParameter extends AbstractParameter +class ProjectParameter extends AbstractParameter { - public const ALLOWED_ELEMENT_CLASS = Device::class; + public const ALLOWED_ELEMENT_CLASS = Project::class; /** - * @var Device the element this para is associated with - * @ORM\ManyToOne(targetEntity="App\Entity\Devices\Device", inversedBy="parameters") + * @var Project the element this para is associated with + * @ORM\ManyToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="parameters") * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE"). */ protected $element; diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 27aeabaa..51573138 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -25,7 +25,8 @@ namespace App\Entity\Parts; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\PartAttachment; -use App\Entity\Devices\Device; +use App\Entity\Parts\PartTraits\ProjectTrait; +use App\Entity\ProjectSystem\Project; use App\Entity\Parameters\ParametersTrait; use App\Entity\Parameters\PartParameter; use App\Entity\Parts\PartTraits\AdvancedPropertyTrait; @@ -33,6 +34,7 @@ use App\Entity\Parts\PartTraits\BasicPropertyTrait; use App\Entity\Parts\PartTraits\InstockTrait; use App\Entity\Parts\PartTraits\ManufacturerTrait; use App\Entity\Parts\PartTraits\OrderTrait; +use App\Entity\ProjectSystem\ProjectBOMEntry; use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -63,11 +65,7 @@ class Part extends AttachmentContainingDBElement use ManufacturerTrait; use OrderTrait; use ParametersTrait; - - /** - * TODO. - */ - protected $devices = []; + use ProjectTrait; /** @var Collection * @Assert\Valid() @@ -120,6 +118,7 @@ class Part extends AttachmentContainingDBElement $this->partLots = new ArrayCollection(); $this->orderdetails = new ArrayCollection(); $this->parameters = new ArrayCollection(); + $this->project_bom_entries = new ArrayCollection(); } public function __clone() @@ -148,16 +147,4 @@ class Part extends AttachmentContainingDBElement } parent::__clone(); } - - /** - * Get all devices which uses this part. - * - * @return Device[] * all devices which uses this part as a one-dimensional array of Device objects - * (empty array if there are no ones) - * * the array is sorted by the devices names - */ - public function getDevices(): array - { - return $this->devices; - } } diff --git a/src/Entity/Parts/PartTraits/ProjectTrait.php b/src/Entity/Parts/PartTraits/ProjectTrait.php new file mode 100644 index 00000000..58697427 --- /dev/null +++ b/src/Entity/Parts/PartTraits/ProjectTrait.php @@ -0,0 +1,81 @@ + $project_bom_entries + * @ORM\OneToMany(targetEntity="App\Entity\ProjectSystem\ProjectBOMEntry", mappedBy="part", cascade={"remove"}, orphanRemoval=true) + */ + protected $project_bom_entries = []; + + /** + * @var Project|null If a project is set here, then this part is special and represents the builds of a project. + * @ORM\OneToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="build_part") + * @ORM\JoinColumn(nullable=true) + */ + protected ?Project $built_project = null; + + /** + * Returns all ProjectBOMEntries that use this part. + * @return Collection|ProjectBOMEntry[] + */ + public function getProjectBomEntries(): Collection + { + return $this->project_bom_entries; + } + + /** + * Checks whether this part represents the builds of a project + * @return bool True if it represents the builds, false if not + */ + public function isProjectBuildPart(): bool + { + return $this->built_project !== null; + } + + /** + * Returns the project that this part represents the builds of, or null if it doesnt + * @return Project|null + */ + public function getBuiltProject(): ?Project + { + return $this->built_project; + } + + + /** + * Sets the project that this part represents the builds of + * @param Project|null $built_project The project that this part represents the builds of, or null if it is not a build part + */ + public function setBuiltProject(?Project $built_project): self + { + $this->built_project = $built_project; + return $this; + } + + + /** + * Get all projects which uses this part. + * + * @return Project[] * all devices which uses this part as a one-dimensional array of Device objects + * (empty array if there are no ones) + * * the array is sorted by the devices names + */ + public function getProjects(): array + { + $projects = []; + + foreach($this->project_bom_entries as $entry) { + $projects[] = $entry->getProject(); + } + + return $projects; + } +} \ No newline at end of file diff --git a/src/Entity/ProjectSystem/Project.php b/src/Entity/ProjectSystem/Project.php new file mode 100644 index 00000000..a319264d --- /dev/null +++ b/src/Entity/ProjectSystem/Project.php @@ -0,0 +1,314 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\ProjectSystem; + +use App\Entity\Attachments\ProjectAttachment; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parameters\ProjectParameter; +use App\Entity\Parts\Part; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use InvalidArgumentException; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; + +/** + * Class AttachmentType. + * + * @ORM\Entity(repositoryClass="App\Repository\Parts\DeviceRepository") + * @ORM\Table(name="projects") + */ +class Project extends AbstractStructuralDBElement +{ + /** + * @ORM\OneToMany(targetEntity="Project", mappedBy="parent") + * @ORM\OrderBy({"name" = "ASC"}) + * @var Collection + */ + protected $children; + + /** + * @ORM\ManyToOne(targetEntity="Project", inversedBy="children") + * @ORM\JoinColumn(name="parent_id", referencedColumnName="id") + */ + protected $parent; + + /** + * @ORM\OneToMany(targetEntity="ProjectBOMEntry", mappedBy="project", cascade={"persist", "remove"}, orphanRemoval=true) + * @Assert\Valid() + */ + protected $bom_entries; + + /** + * @ORM\Column(type="integer") + */ + protected int $order_quantity = 0; + + /** + * @var string The current status of the project + * @ORM\Column(type="string", length=64, nullable=true) + * @Assert\Choice({"draft","planning","in_production","finished","archived"}) + */ + protected ?string $status = null; + + + /** + * @var Part|null The (optional) part that represents the builds of this project in the stock + * @ORM\OneToOne(targetEntity="App\Entity\Parts\Part", mappedBy="built_project", cascade={"persist"}, orphanRemoval=true) + */ + protected ?Part $build_part = null; + + /** + * @ORM\Column(type="boolean") + */ + protected bool $order_only_missing_parts = false; + + /** + * @ORM\Column(type="text", nullable=false, columnDefinition="DEFAULT ''") + */ + protected string $description = ''; + + /** + * @var Collection + * @ORM\OneToMany(targetEntity="App\Entity\Attachments\ProjectAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) + * @ORM\OrderBy({"name" = "ASC"}) + */ + protected $attachments; + + /** @var Collection + * @ORM\OneToMany(targetEntity="App\Entity\Parameters\ProjectParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) + * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"}) + */ + protected $parameters; + + /******************************************************************************** + * + * Getters + * + *********************************************************************************/ + + public function __construct() + { + parent::__construct(); + $this->bom_entries = new ArrayCollection(); + } + + public function __clone() + { + //When cloning this project, we have to clone each bom entry too. + if ($this->id) { + $bom_entries = $this->bom_entries; + $this->bom_entries = new ArrayCollection(); + //Set master attachment is needed + foreach ($bom_entries as $bom_entry) { + $clone = clone $bom_entry; + $this->bom_entries->add($clone); + } + } + + //Parent has to be last call, as it resets the ID + parent::__clone(); + } + + /** + * Get the order quantity of this device. + * + * @return int the order quantity + */ + public function getOrderQuantity(): int + { + return $this->order_quantity; + } + + /** + * Get the "order_only_missing_parts" attribute. + * + * @return bool the "order_only_missing_parts" attribute + */ + public function getOrderOnlyMissingParts(): bool + { + return $this->order_only_missing_parts; + } + + /******************************************************************************** + * + * Setters + * + *********************************************************************************/ + + /** + * Set the order quantity. + * + * @param int $new_order_quantity the new order quantity + * + * @return $this + */ + public function setOrderQuantity(int $new_order_quantity): self + { + if ($new_order_quantity < 0) { + throw new InvalidArgumentException('The new order quantity must not be negative!'); + } + $this->order_quantity = $new_order_quantity; + + return $this; + } + + /** + * Set the "order_only_missing_parts" attribute. + * + * @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute + * + * @return Project + */ + public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self + { + $this->order_only_missing_parts = $new_order_only_missing_parts; + + return $this; + } + + /** + * @return Collection|ProjectBOMEntry[] + */ + public function getBomEntries(): Collection + { + return $this->bom_entries; + } + + /** + * @param ProjectBOMEntry $entry + * @return $this + */ + public function addBomEntry(ProjectBOMEntry $entry): self + { + $entry->setProject($this); + $this->bom_entries->add($entry); + return $this; + } + + /** + * @param ProjectBOMEntry $entry + * @return $this + */ + public function removeBomEntry(ProjectBOMEntry $entry): self + { + $this->bom_entries->removeElement($entry); + return $this; + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @param string $description + * @return Project + */ + public function setDescription(string $description): Project + { + $this->description = $description; + return $this; + } + + /** + * @return string + */ + public function getStatus(): ?string + { + return $this->status; + } + + /** + * @param string $status + */ + public function setStatus(?string $status): void + { + $this->status = $status; + } + + /** + * Checks if this project has a associated part representing the builds of this project in the stock. + * @return bool + */ + public function hasBuildPart(): bool + { + return $this->build_part !== null; + } + + /** + * Gets the part representing the builds of this project in the stock, if it is existing + * @return Part|null + */ + public function getBuildPart(): ?Part + { + return $this->build_part; + } + + /** + * Sets the part representing the builds of this project in the stock. + * @param Part|null $build_part + */ + public function setBuildPart(?Part $build_part): void + { + $this->build_part = $build_part; + if ($build_part) { + $build_part->setBuiltProject($this); + } + } + + /** + * @Assert\Callback + */ + public function validate(ExecutionContextInterface $context, $payload) + { + //If this project has subprojects, and these have builds part, they must be included in the BOM + foreach ($this->getChildren() as $child) { + /** @var $child Project */ + if ($child->getBuildPart() === null) { + continue; + } + //We have to search all bom entries for the build part + $found = false; + foreach ($this->getBomEntries() as $bom_entry) { + if ($bom_entry->getPart() === $child->getBuildPart()) { + $found = true; + break; + } + } + + //When the build part is not found, we have to add an error + if (!$found) { + $context->buildViolation('project.bom_has_to_include_all_subelement_parts') + ->atPath('bom_entries') + ->setParameter('%project_name%', $child->getName()) + ->setParameter('%part_name%', $child->getBuildPart()->getName()) + ->addViolation(); + } + } + } +} diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php new file mode 100644 index 00000000..3c874a7b --- /dev/null +++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php @@ -0,0 +1,324 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\ProjectSystem; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Base\TimestampTrait; +use App\Entity\Parts\Part; +use App\Entity\PriceInformations\Currency; +use App\Validator\Constraints\BigDecimal\BigDecimalPositive; +use App\Validator\Constraints\Selectable; +use Brick\Math\BigDecimal; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; + +/** + * The ProjectBOMEntry class represents a entry in a project's BOM. + * + * @ORM\Table("project_bom_entries") + * @ORM\HasLifecycleCallbacks() + * @ORM\Entity() + * @UniqueEntity(fields={"part", "project"}, message="project.bom_entry.part_already_in_bom") + * @UniqueEntity(fields={"name", "project"}, message="project.bom_entry.name_already_in_bom", ignoreNull=true) + */ +class ProjectBOMEntry extends AbstractDBElement +{ + use TimestampTrait; + + /** + * @var float + * @ORM\Column(type="float", name="quantity") + * @Assert\Positive() + */ + protected float $quantity; + + /** + * @var string A comma separated list of the names, where this parts should be placed + * @ORM\Column(type="text", name="mountnames") + */ + protected string $mountnames; + + /** + * @var string An optional name describing this BOM entry (useful for non-part entries) + * @ORM\Column(type="string", nullable=true) + * @Assert\Expression( + * "this.getPart() !== null or this.getName() !== null", + * message="validator.project.bom_entry.name_or_part_needed" + * ) + */ + protected ?string $name = null; + + /** + * @var string An optional comment for this BOM entry + * @ORM\Column(type="text") + */ + protected string $comment; + + /** + * @var Project + * @ORM\ManyToOne(targetEntity="Project", inversedBy="bom_entries") + * @ORM\JoinColumn(name="id_device", referencedColumnName="id") + */ + protected ?Project $project = null; + + /** + * @var Part|null The part associated with this + * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part", inversedBy="project_bom_entries") + * @ORM\JoinColumn(name="id_part", referencedColumnName="id", nullable=true) + */ + protected ?Part $part = null; + + /** + * @var BigDecimal The price of this non-part BOM entry + * @ORM\Column(type="big_decimal", precision=11, scale=5, nullable=true) + * @Assert\AtLeastOneOf({ + * @BigDecimalPositive(), + * @Assert\IsNull() + * }) + */ + protected ?BigDecimal $price; + + /** + * @var ?Currency The currency for the price of this non-part BOM entry + * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency") + * @ORM\JoinColumn(nullable=true) + * @Selectable() + */ + protected ?Currency $price_currency = null; + + public function __construct() + { + $this->price = BigDecimal::zero()->toScale(5); + } + + /** + * @return float + */ + public function getQuantity(): float + { + return $this->quantity; + } + + /** + * @param float $quantity + * @return ProjectBOMEntry + */ + public function setQuantity(float $quantity): ProjectBOMEntry + { + $this->quantity = $quantity; + return $this; + } + + /** + * @return string + */ + public function getMountnames(): string + { + return $this->mountnames; + } + + /** + * @param string $mountnames + * @return ProjectBOMEntry + */ + public function setMountnames(string $mountnames): ProjectBOMEntry + { + $this->mountnames = $mountnames; + return $this; + } + + /** + * @return string + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string $name + * @return ProjectBOMEntry + */ + public function setName(?string $name): ProjectBOMEntry + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getComment(): string + { + return $this->comment; + } + + /** + * @param string $comment + * @return ProjectBOMEntry + */ + public function setComment(string $comment): ProjectBOMEntry + { + $this->comment = $comment; + return $this; + } + + /** + * @return Project|null + */ + public function getProject(): ?Project + { + return $this->project; + } + + /** + * @param Project|null $project + * @return ProjectBOMEntry + */ + public function setProject(?Project $project): ProjectBOMEntry + { + $this->project = $project; + return $this; + } + + + + /** + * @return Part|null + */ + public function getPart(): ?Part + { + return $this->part; + } + + /** + * @param Part|null $part + * @return ProjectBOMEntry + */ + public function setPart(?Part $part): ProjectBOMEntry + { + $this->part = $part; + return $this; + } + + /** + * Returns the price of this BOM entry, if existing. + * Prices are only valid on non-Part BOM entries. + * @return BigDecimal|null + */ + public function getPrice(): ?BigDecimal + { + return $this->price; + } + + /** + * Sets the price of this BOM entry. + * Prices are only valid on non-Part BOM entries. + * @param BigDecimal|null $price + */ + public function setPrice(?BigDecimal $price): void + { + $this->price = $price; + } + + /** + * @return Currency|null + */ + public function getPriceCurrency(): ?Currency + { + return $this->price_currency; + } + + /** + * @param Currency|null $price_currency + */ + public function setPriceCurrency(?Currency $price_currency): void + { + $this->price_currency = $price_currency; + } + + + + /** + * @Assert\Callback + */ + public function validate(ExecutionContextInterface $context, $payload): void + { + //Round quantity to whole numbers, if the part is not a decimal part + if ($this->part) { + if (!$this->part->getPartUnit() || $this->part->getPartUnit()->isInteger()) { + $this->quantity = round($this->quantity); + } + } + //Non-Part BOM entries are rounded + if ($this->part === null) { + $this->quantity = round($this->quantity); + } + + //Check that every part name in the mountnames list is unique (per bom_entry) + $mountnames = explode(',', $this->mountnames); + $mountnames = array_map('trim', $mountnames); + $uniq_mountnames = array_unique($mountnames); + + //If the number of unique names is not the same as the number of names, there are duplicates + if (count($mountnames) !== count($uniq_mountnames)) { + $context->buildViolation('project.bom_entry.mountnames_not_unique') + ->atPath('mountnames') + ->addViolation(); + } + + //Check that the number of mountnames is the same as the (rounded) quantity + if (!empty($this->mountnames) && count($uniq_mountnames) !== (int) round ($this->quantity)) { + $context->buildViolation('project.bom_entry.mountnames_quantity_mismatch') + ->atPath('mountnames') + ->addViolation(); + } + + //Prices are only only allowed on non-part BOM entries + if ($this->part !== null && $this->price !== null) { + $context->buildViolation('project.bom_entry.price_not_allowed_on_parts') + ->atPath('price') + ->addViolation(); + } + + //Check that the part is not the build representation part of this device or one of its parents + if ($this->part && $this->part->getBuiltProject() !== null) { + //Get the associated project + $associated_project = $this->part->getBuiltProject(); + //Check that it is not the same as the current project neither one of its parents + $current_project = $this->project; + while ($current_project) { + if ($associated_project === $current_project) { + $context->buildViolation('project.bom_entry.can_not_add_own_builds_part') + ->atPath('part') + ->addViolation(); + } + $current_project = $current_project->getParent(); + } + } + } + + +} diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index b5881a14..0ca9cff3 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -37,6 +37,11 @@ final class PermissionData implements \JsonSerializable public const ALLOW = true; public const DISALLOW = false; + /** + * The current schema version of the permission data + */ + public const CURRENT_SCHEMA_VERSION = 2; + /** * @var array This array contains the permission values for each permission * This array contains the permission values for each permission, in the form of: @@ -45,7 +50,10 @@ final class PermissionData implements \JsonSerializable * ] * @ORM\Column(type="json", name="data", options={"default": "[]"}) */ - protected ?array $data = []; + protected ?array $data = [ + //$ prefixed entries are used for metadata + '$ver' => self::CURRENT_SCHEMA_VERSION, //The schema version of the permission data + ]; /** * Creates a new Permission Data Instance using the given data. @@ -54,6 +62,61 @@ final class PermissionData implements \JsonSerializable public function __construct(array $data = []) { $this->data = $data; + + //If the passed data did not contain a schema version, we set it to the current version + if (!isset($this->data['$ver'])) { + $this->data['$ver'] = self::CURRENT_SCHEMA_VERSION; + } + } + + /** + * Checks if any of the operations of the given permission is defined (meaning it is either ALLOW or DENY) + * @param string $permission + * @return bool + */ + public function isAnyOperationOfPermissionSet(string $permission): bool + { + return !empty($this->data[$permission]); + } + + /** + * Returns an associative array containing all defined (non-INHERIT) operations of the given permission. + * @param string $permission + * @return array An array in the form ["operation" => value], returns an empty array if no operations are defined + */ + public function getAllDefinedOperationsOfPermission(string $permission): array + { + if (empty($this->data[$permission])) { + return []; + } + + return $this->data[$permission]; + } + + /** + * Sets all operations of the given permission via the given array. + * The data is an array in the form [$operation => $value], all existing values will be overwritten/deleted. + * @param string $permission + * @param array $data + * @return $this + */ + public function setAllOperationsOfPermission(string $permission, array $data): self + { + $this->data[$permission] = $data; + + return $this; + } + + /** + * Removes a whole permission from the data including all operations (effectivly setting them to INHERIT) + * @param string $permission + * @return $this + */ + public function removePermission(string $permission): self + { + unset($this->data[$permission]); + + return $this; } /** @@ -64,6 +127,11 @@ final class PermissionData implements \JsonSerializable */ public function isPermissionSet(string $permission, string $operation): bool { + //We cannot access metadata via normal permission data + if (strpos($permission, '$') !== false) { + return false; + } + return isset($this->data[$permission][$operation]); } @@ -143,6 +211,11 @@ final class PermissionData implements \JsonSerializable //Filter out all empty or null values foreach ($this->data as $permission => $operations) { + //Skip non-array values + if (!is_array($operations)) { + continue; + } + $ret[$permission] = array_filter($operations, function ($value) { return $value !== null; }); @@ -155,4 +228,29 @@ final class PermissionData implements \JsonSerializable return $ret; } + + /** + * Returns the schema version of the permission data. + * @return int The schema version of the permission data + */ + public function getSchemaVersion(): int + { + return $this->data['$ver'] ?? 0; + } + + /** + * Sets the schema version of this permission data + * @param int $new_version + * @return $this + */ + public function setSchemaVersion(int $new_version): self + { + if ($new_version < 0) { + throw new \InvalidArgumentException('The schema version must be a positive integer'); + } + + $this->data['$ver'] = $new_version; + return $this; + } + } \ No newline at end of file diff --git a/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php b/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php new file mode 100644 index 00000000..7d891df0 --- /dev/null +++ b/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php @@ -0,0 +1,77 @@ +. + */ + +namespace App\EventSubscriber\UserSystem; + +use App\Entity\UserSystem\User; +use App\Services\LogSystem\EventCommentHelper; +use App\Services\UserSystem\PermissionSchemaUpdater; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Security; + +/** + * The purpose of this event subscriber is to check if the permission schema of the current user is up to date and upgrade it automatically if needed. + */ +class UpgradePermissionsSchemaSubscriber implements EventSubscriberInterface +{ + private Security $security; + private PermissionSchemaUpdater $permissionSchemaUpdater; + private EntityManagerInterface $entityManager; + private FlashBagInterface $flashBag; + private EventCommentHelper $eventCommentHelper; + + public function __construct(Security $security, PermissionSchemaUpdater $permissionSchemaUpdater, EntityManagerInterface $entityManager, FlashBagInterface $flashBag, EventCommentHelper $eventCommentHelper) + { + $this->security = $security; + $this->permissionSchemaUpdater = $permissionSchemaUpdater; + $this->entityManager = $entityManager; + $this->flashBag = $flashBag; + $this->eventCommentHelper = $eventCommentHelper; + } + + public function onRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $user = $this->security->getUser(); + if (null === $user) { + //Retrieve anonymous user + $user = $this->entityManager->getRepository(User::class)->getAnonymousUser(); + } + + if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($user)) { + $this->eventCommentHelper->setMessage('Automatic permission schema update'); + $this->permissionSchemaUpdater->userUpgradeSchemaRecursively($user); + $this->entityManager->flush(); + $this->flashBag->add('notice', 'user.permissions_schema_updated'); + } + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::REQUEST => 'onRequest']; + } +} \ No newline at end of file diff --git a/src/Form/AdminPages/ProjectAdminForm.php b/src/Form/AdminPages/ProjectAdminForm.php new file mode 100644 index 00000000..3547d094 --- /dev/null +++ b/src/Form/AdminPages/ProjectAdminForm.php @@ -0,0 +1,64 @@ +. + */ + +namespace App\Form\AdminPages; + +use App\Entity\Base\AbstractNamedDBElement; +use App\Form\ProjectSystem\ProjectBOMEntryCollectionType; +use App\Form\ProjectSystem\ProjectBOMEntryType; +use App\Form\Type\RichTextEditorType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\FormBuilderInterface; + +class ProjectAdminForm extends BaseEntityAdminForm +{ + protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void + { + $builder->add('description', RichTextEditorType::class, [ + 'required' => false, + 'label' => 'part.edit.description', + 'mode' => 'markdown-single_line', + 'empty_data' => '', + 'attr' => [ + 'placeholder' => 'part.edit.description.placeholder', + 'rows' => 2, + ], + ]); + + $builder->add('bom_entries', ProjectBOMEntryCollectionType::class); + + $builder->add('status', ChoiceType::class, [ + 'attr' => [ + 'class' => 'form-select', + ], + 'label' => 'project.edit.status', + 'required' => false, + 'empty_data' => '', + 'choices' => [ + 'project.status.draft' => 'draft', + 'project.status.planning' => 'planning', + 'project.status.in_production' => 'in_production', + 'project.status.finished' => 'finished', + 'project.status.archived' => 'archived', + ], + ]); + } +} \ No newline at end of file diff --git a/src/Form/AdminPages/StorelocationAdminForm.php b/src/Form/AdminPages/StorelocationAdminForm.php index 9caf1169..8a85e4ec 100644 --- a/src/Form/AdminPages/StorelocationAdminForm.php +++ b/src/Form/AdminPages/StorelocationAdminForm.php @@ -38,21 +38,21 @@ class StorelocationAdminForm extends BaseEntityAdminForm 'required' => false, 'label' => 'storelocation.edit.is_full.label', 'help' => 'storelocation.edit.is_full.help', - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); $builder->add('limit_to_existing_parts', CheckboxType::class, [ 'required' => false, 'label' => 'storelocation.limit_to_existing.label', 'help' => 'storelocation.limit_to_existing.help', - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); $builder->add('only_single_part', CheckboxType::class, [ 'required' => false, 'label' => 'storelocation.only_single_part.label', 'help' => 'storelocation.only_single_part.help', - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); $builder->add('storage_type', StructuralEntityType::class, [ @@ -61,7 +61,7 @@ class StorelocationAdminForm extends BaseEntityAdminForm 'help' => 'storelocation.storage_type.help', 'class' => MeasurementUnit::class, 'disable_not_selectable' => true, - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); } } diff --git a/src/Form/Filters/AttachmentFilterType.php b/src/Form/Filters/AttachmentFilterType.php index eeea0fa9..57967be7 100644 --- a/src/Form/Filters/AttachmentFilterType.php +++ b/src/Form/Filters/AttachmentFilterType.php @@ -25,7 +25,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentTypeAttachment; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; -use App\Entity\Attachments\DeviceAttachment; +use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; use App\Entity\Attachments\LabelAttachment; @@ -80,7 +80,7 @@ class AttachmentFilterType extends AbstractType 'attachment_type.label' => AttachmentTypeAttachment::class, 'category.label' => CategoryAttachment::class, 'currency.label' => CurrencyAttachment::class, - 'device.label' => DeviceAttachment::class, + 'project.label' => ProjectAttachment::class, 'footprint.label' => FootprintAttachment::class, 'group.label' => GroupAttachment::class, 'label_profile.label' => LabelAttachment::class, diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php index 39393917..f7b460b6 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -23,8 +23,9 @@ namespace App\Form\Filters; use App\DataTables\Filters\LogFilter; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; -use App\Entity\Devices\Device; -use App\Entity\Devices\DevicePart; +use App\Entity\LogSystem\PartStockChangedLogEntry; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\LabelSystem\LabelProfile; use App\Entity\LogSystem\AbstractLogEntry; use App\Entity\LogSystem\CollectionElementDeleted; @@ -32,7 +33,7 @@ use App\Entity\LogSystem\DatabaseUpdatedLogEntry; use App\Entity\LogSystem\ElementCreatedLogEntry; use App\Entity\LogSystem\ElementDeletedLogEntry; use App\Entity\LogSystem\ElementEditedLogEntry; -use App\Entity\LogSystem\InstockChangedLogEntry; +use App\Entity\LogSystem\LegacyInstockChangedLogEntry; use App\Entity\LogSystem\SecurityEventLogEntry; use App\Entity\LogSystem\UserLoginLogEntry; use App\Entity\LogSystem\UserLogoutLogEntry; @@ -86,9 +87,10 @@ class LogFilterType extends AbstractType 'log.type.user_login' => UserLoginLogEntry::class, 'log.type.user_logout' => UserLogoutLogEntry::class, 'log.type.user_not_allowed' => UserNotAllowedLogEntry::class, + 'log.type.part_stock_changed' => PartStockChangedLogEntry::class, //Legacy entries - 'log.type.instock_changed' => InstockChangedLogEntry::class, + 'log.type.instock_changed' => LegacyInstockChangedLogEntry::class, ]; public function configureOptions(OptionsResolver $resolver): void @@ -135,8 +137,8 @@ class LogFilterType extends AbstractType 'attachment.label' => AbstractLogEntry::targetTypeClassToID(Attachment::class), 'attachment_type.label' => AbstractLogEntry::targetTypeClassToID(AttachmentType::class), 'category.label' => AbstractLogEntry::targetTypeClassToID(Category::class), - 'device.label' => AbstractLogEntry::targetTypeClassToID(Device::class), - 'device_part.label' => AbstractLogEntry::targetTypeClassToID(DevicePart::class), + 'project.label' => AbstractLogEntry::targetTypeClassToID(Project::class), + 'project_bom_entry.label' => AbstractLogEntry::targetTypeClassToID(ProjectBOMEntry::class), 'footprint.label' => AbstractLogEntry::targetTypeClassToID(Footprint::class), 'group.label' => AbstractLogEntry::targetTypeClassToID(Group::class), 'manufacturer.label' => AbstractLogEntry::targetTypeClassToID(Manufacturer::class), diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php index ef993c40..09293b97 100644 --- a/src/Form/ParameterType.php +++ b/src/Form/ParameterType.php @@ -45,7 +45,7 @@ use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AttachmentTypeParameter; use App\Entity\Parameters\CategoryParameter; use App\Entity\Parameters\CurrencyParameter; -use App\Entity\Parameters\DeviceParameter; +use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\FootprintParameter; use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; @@ -158,7 +158,7 @@ class ParameterType extends AbstractType AttachmentTypeParameter::class => 'attachment_type', CategoryParameter::class => 'category', CurrencyParameter::class => 'currency', - DeviceParameter::class => 'device', + ProjectParameter::class => 'device', FootprintParameter::class => 'footprint', GroupParameter::class => 'group', ManufacturerParameter::class => 'manufacturer', diff --git a/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php b/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php new file mode 100644 index 00000000..71c745c7 --- /dev/null +++ b/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php @@ -0,0 +1,35 @@ +setDefaults([ + 'entry_type' => ProjectBOMEntryType::class, + 'entry_options' => [ + 'label' => false, + ], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'reindex_enable' => true, + 'label' => false, + ]); + } + + public function getBlockPrefix() + { + return 'project_bom_entry_collection'; + } +} \ No newline at end of file diff --git a/src/Form/ProjectSystem/ProjectBOMEntryType.php b/src/Form/ProjectSystem/ProjectBOMEntryType.php new file mode 100644 index 00000000..49292235 --- /dev/null +++ b/src/Form/ProjectSystem/ProjectBOMEntryType.php @@ -0,0 +1,100 @@ +addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) { + $form = $event->getForm(); + /** @var ProjectBOMEntry $data */ + $data = $event->getData(); + + $form->add('quantity', SIUnitType::class, [ + 'label' => 'project.bom.quantity', + 'measurement_unit' => $data && $data->getPart() ? $data->getPart()->getPartUnit() : null, + ]); + }); + + $builder + + ->add('part', PartSelectType::class, [ + 'required' => false, + ]) + + ->add('name', TextType::class, [ + 'label' => 'project.bom.name', + 'required' => false, + ]) + ->add('mountnames', TextType::class, [ + 'required' => false, + 'label' => 'project.bom.mountnames', + 'empty_data' => '', + 'attr' => [ + 'class' => 'tagsinput', + 'data-controller' => 'elements--tagsinput', + ] + ]) + ->add('comment', RichTextEditorType::class, [ + 'required' => false, + 'label' => 'project.bom.comment', + 'empty_data' => '', + 'mode' => 'markdown-single_line', + 'attr' => [ + 'rows' => 2, + ], + ]) + ->add('price', BigDecimalNumberType::class, [ + 'label' => false, + 'required' => false, + 'scale' => 5, + 'html5' => true, + 'attr' => [ + 'min' => 0, + 'step' => 'any', + ], + ]) + ->add('priceCurrency', CurrencyEntityType::class, [ + 'required' => false, + 'label' => false, + 'short' => true, + ]) + + ; + + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ProjectBOMEntry::class, + ]); + } + + + public function getBlockPrefix() + { + return 'project_bom_entry'; + } +} \ No newline at end of file diff --git a/src/Form/Type/PartSelectType.php b/src/Form/Type/PartSelectType.php new file mode 100644 index 00000000..5ca543f7 --- /dev/null +++ b/src/Form/Type/PartSelectType.php @@ -0,0 +1,137 @@ +urlGenerator = $urlGenerator; + $this->em = $em; + $this->previewGenerator = $previewGenerator; + $this->attachmentURLGenerator = $attachmentURLGenerator; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + //At initialization we have to fill the form element with our selected data, so the user can see it + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) { + $form = $event->getForm(); + $config = $form->getConfig()->getOptions(); + $data = $event->getData() ?? []; + + $config['compound'] = false; + $config['choices'] = is_iterable($data) ? $data : [$data]; + $config['error_bubbling'] = true; + + $form->add('autocomplete', EntityType::class, $config); + }); + + //After form submit, we have to add the selected element as choice, otherwise the form will not accept this element + $builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) { + $data = $event->getData(); + $form = $event->getForm(); + $options = $form->get('autocomplete')->getConfig()->getOptions(); + + + if (!isset($data['autocomplete']) || '' === $data['autocomplete']) { + $options['choices'] = []; + } else { + //Extract the ID from the submitted data + $id = $data['autocomplete']; + //Find the element in the database + $element = $this->em->find($options['class'], $id); + + //Add the element as choice + $options['choices'] = [$element]; + $options['error_bubbling'] = true; + $form->add('autocomplete', EntityType::class, $options); + } + }); + + $builder->setDataMapper($this); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => Part::class, + 'choice_label' => 'name', + //'placeholder' => 'None', + 'compound' => true, + 'error_bubbling' => false, + ]); + + $resolver->setDefaults([ + 'attr' => [ + 'data-controller' => 'elements--part-select', + 'data-autocomplete' => $this->urlGenerator->generate('typeahead_parts', ['query' => '__QUERY__']), + //Disable browser autocomplete + 'autocomplete' => 'off', + + ], + ]); + + $resolver->setDefaults([ + //Prefill the selected choice with the needed data, so the user can see it without an additional Ajax request + 'choice_attr' => ChoiceList::attr($this, function (?Part $part) { + if($part) { + //Determine the picture to show: + $preview_attachment = $this->previewGenerator->getTablePreviewAttachment($part); + if ($preview_attachment !== null) { + $preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, + 'thumbnail_sm'); + } else { + $preview_url = ''; + } + } + + return $part ? [ + 'data-description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), + 'data-category' => $part->getCategory() ? $part->getCategory()->getName() : '', + 'data-footprint' => $part->getFootprint() ? $part->getFootprint()->getName() : '', + 'data-image' => $preview_url, + ] : []; + }) + ]); + } + + public function getBlockPrefix() + { + return 'part_select'; + } + + public function mapDataToForms($data, $forms) + { + $form = current(iterator_to_array($forms, false)); + $form->setData($data); + } + + public function mapFormsToData($forms, &$data) + { + $form = current(iterator_to_array($forms, false)); + $data = $form->getData(); + } + +} \ No newline at end of file diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 25b07eb6..5dfb8f45 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -66,4 +66,25 @@ class PartRepository extends NamedDBElementRepository return (int) ($query->getSingleScalarResult() ?? 0); } + + public function autocompleteSearch(string $query, int $max_limits = 50): array + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.footprint', 'footprint') + + ->where('part.name LIKE :query') + ->orWhere('part.description LIKE :query') + ->orWhere('category.name LIKE :query') + ->orWhere('footprint.name LIKE :query') + ; + + $qb->setParameter('query', '%'.$query.'%'); + + $qb->setMaxResults($max_limits); + $qb->orderBy('part.name', 'ASC'); + + return $qb->getQuery()->getResult(); + } } diff --git a/src/Repository/Parts/DeviceRepository.php b/src/Repository/Parts/DeviceRepository.php index 013958c1..dc5d5acc 100644 --- a/src/Repository/Parts/DeviceRepository.php +++ b/src/Repository/Parts/DeviceRepository.php @@ -22,18 +22,19 @@ namespace App\Repository\Parts; use App\Entity\Base\AbstractPartsContainingDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Part; use App\Repository\AbstractPartsContainingRepository; +use App\Repository\StructuralDBElementRepository; use InvalidArgumentException; -class DeviceRepository extends AbstractPartsContainingRepository +class DeviceRepository extends StructuralDBElementRepository { public function getParts(object $element, array $order_by = ['name' => 'ASC']): array { - if (!$element instanceof Device) { + if (!$element instanceof Project) { throw new InvalidArgumentException('$element must be an Device!'); } @@ -44,7 +45,7 @@ class DeviceRepository extends AbstractPartsContainingRepository public function getPartsCount(object $element): int { - if (!$element instanceof Device) { + if (!$element instanceof Project) { throw new InvalidArgumentException('$element must be an Device!'); } diff --git a/src/Security/Voter/ParameterVoter.php b/src/Security/Voter/ParameterVoter.php index c09a8e04..525a75b6 100644 --- a/src/Security/Voter/ParameterVoter.php +++ b/src/Security/Voter/ParameterVoter.php @@ -24,7 +24,7 @@ use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AttachmentTypeParameter; use App\Entity\Parameters\CategoryParameter; use App\Entity\Parameters\CurrencyParameter; -use App\Entity\Parameters\DeviceParameter; +use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\FootprintParameter; use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; @@ -95,8 +95,8 @@ class ParameterVoter extends ExtendedVoter $param = 'categories'; } elseif ($subject instanceof CurrencyParameter) { $param = 'currencies'; - } elseif ($subject instanceof DeviceParameter) { - $param = 'devices'; + } elseif ($subject instanceof ProjectParameter) { + $param = 'projects'; } elseif ($subject instanceof FootprintParameter) { $param = 'footprints'; } elseif ($subject instanceof GroupParameter) { diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php index 7886638f..da05070b 100644 --- a/src/Security/Voter/PartLotVoter.php +++ b/src/Security/Voter/PartLotVoter.php @@ -57,7 +57,7 @@ class PartLotVoter extends ExtendedVoter $this->security = $security; } - protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; + protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move']; protected function voteOnUser(string $attribute, $subject, User $user): bool { @@ -65,6 +65,11 @@ class PartLotVoter extends ExtendedVoter throw new \RuntimeException('This voter can only handle PartLot objects!'); } + if (in_array($attribute, ['withdraw', 'add', 'move'])) + { + return $this->resolver->inherit($user, 'parts_stock', $attribute) ?? false; + } + switch ($attribute) { case 'read': $operation = 'read'; diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php index 50f21fd1..df88e113 100644 --- a/src/Security/Voter/StructureVoter.php +++ b/src/Security/Voter/StructureVoter.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace App\Security\Voter; use App\Entity\Attachments\AttachmentType; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -40,7 +40,7 @@ class StructureVoter extends ExtendedVoter protected const OBJ_PERM_MAP = [ AttachmentType::class => 'attachment_types', Category::class => 'categories', - Device::class => 'devices', + Project::class => 'projects', Footprint::class => 'footprints', Manufacturer::class => 'manufacturers', Storelocation::class => 'storelocations', diff --git a/src/Services/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php index 9feaa11c..4dcf1a65 100644 --- a/src/Services/Attachments/AttachmentSubmitHandler.php +++ b/src/Services/Attachments/AttachmentSubmitHandler.php @@ -28,7 +28,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentTypeAttachment; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; -use App\Entity\Attachments\DeviceAttachment; +use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; use App\Entity\Attachments\ManufacturerAttachment; @@ -82,7 +82,7 @@ class AttachmentSubmitHandler AttachmentTypeAttachment::class => 'attachment_type', CategoryAttachment::class => 'category', CurrencyAttachment::class => 'currency', - DeviceAttachment::class => 'device', + ProjectAttachment::class => 'device', FootprintAttachment::class => 'footprint', GroupAttachment::class => 'group', ManufacturerAttachment::class => 'manufacturer', diff --git a/src/Services/Attachments/PartPreviewGenerator.php b/src/Services/Attachments/PartPreviewGenerator.php index d47e873c..39d1c65c 100644 --- a/src/Services/Attachments/PartPreviewGenerator.php +++ b/src/Services/Attachments/PartPreviewGenerator.php @@ -62,6 +62,13 @@ class PartPreviewGenerator } } + if (null !== $part->getBuiltProject()) { + $attachment = $part->getBuiltProject()->getMasterPictureAttachment(); + if ($this->isAttachmentValidPicture($attachment)) { + $list[] = $attachment; + } + } + if (null !== $part->getCategory()) { $attachment = $part->getCategory()->getMasterPictureAttachment(); if ($this->isAttachmentValidPicture($attachment)) { @@ -109,7 +116,7 @@ class PartPreviewGenerator return $attachment; } - //Otherwise check if the part has a footprint with a valid masterattachment + //Otherwise check if the part has a footprint with a valid master attachment if (null !== $part->getFootprint()) { $attachment = $part->getFootprint()->getMasterPictureAttachment(); if ($this->isAttachmentValidPicture($attachment)) { @@ -117,6 +124,14 @@ class PartPreviewGenerator } } + //With lowest priority use the master attachment of the project this part represents (when existing) + if (null !== $part->getBuiltProject()) { + $attachment = $part->getBuiltProject()->getMasterPictureAttachment(); + if ($this->isAttachmentValidPicture($attachment)) { + return $attachment; + } + } + //If nothing is available return null return null; } diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 3e456da7..adee61ad 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -25,7 +25,7 @@ namespace App\Services; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Contracts\NamedElementInterface; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -39,6 +39,7 @@ use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; +use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; use App\Exceptions\EntityNotSupportedException; @@ -59,7 +60,8 @@ class ElementTypeNameGenerator Attachment::class => $this->translator->trans('attachment.label'), Category::class => $this->translator->trans('category.label'), AttachmentType::class => $this->translator->trans('attachment_type.label'), - Device::class => $this->translator->trans('device.label'), + Project::class => $this->translator->trans('project.label'), + ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'), Footprint::class => $this->translator->trans('footprint.label'), Manufacturer::class => $this->translator->trans('manufacturer.label'), MeasurementUnit::class => $this->translator->trans('measurement_unit.label'), diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php index 2d0afd88..1ed659e4 100644 --- a/src/Services/EntityURLGenerator.php +++ b/src/Services/EntityURLGenerator.php @@ -26,7 +26,7 @@ use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\PartAttachment; use App\Entity\Base\AbstractDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -113,7 +113,7 @@ class EntityURLGenerator //As long we does not have own things for it use edit page AttachmentType::class => 'attachment_type_edit', Category::class => 'category_edit', - Device::class => 'device_edit', + Project::class => 'project_edit', Supplier::class => 'supplier_edit', Manufacturer::class => 'manufacturer_edit', Storelocation::class => 'store_location_edit', @@ -204,7 +204,7 @@ class EntityURLGenerator //As long we does not have own things for it use edit page AttachmentType::class => 'attachment_type_edit', Category::class => 'category_edit', - Device::class => 'device_edit', + Project::class => 'project_info', Supplier::class => 'supplier_edit', Manufacturer::class => 'manufacturer_edit', Storelocation::class => 'store_location_edit', @@ -234,7 +234,7 @@ class EntityURLGenerator Part::class => 'part_edit', AttachmentType::class => 'attachment_type_edit', Category::class => 'category_edit', - Device::class => 'device_edit', + Project::class => 'project_edit', Supplier::class => 'supplier_edit', Manufacturer::class => 'manufacturer_edit', Storelocation::class => 'store_location_edit', @@ -264,7 +264,7 @@ class EntityURLGenerator Part::class => 'part_new', AttachmentType::class => 'attachment_type_new', Category::class => 'category_new', - Device::class => 'device_new', + Project::class => 'project_new', Supplier::class => 'supplier_new', Manufacturer::class => 'manufacturer_new', Storelocation::class => 'store_location_new', @@ -295,7 +295,7 @@ class EntityURLGenerator Part::class => 'part_clone', AttachmentType::class => 'attachment_type_clone', Category::class => 'category_clone', - Device::class => 'device_clone', + Project::class => 'device_clone', Supplier::class => 'supplier_clone', Manufacturer::class => 'manufacturer_clone', Storelocation::class => 'store_location_clone', @@ -322,6 +322,8 @@ class EntityURLGenerator public function listPartsURL(AbstractDBElement $entity): string { $map = [ + Project::class => 'project_info', + Category::class => 'part_list_category', Footprint::class => 'part_list_footprint', Manufacturer::class => 'part_list_manufacturer', @@ -338,7 +340,7 @@ class EntityURLGenerator Part::class => 'part_delete', AttachmentType::class => 'attachment_type_delete', Category::class => 'category_delete', - Device::class => 'device_delete', + Project::class => 'project_delete', Supplier::class => 'supplier_delete', Manufacturer::class => 'manufacturer_delete', Storelocation::class => 'store_location_delete', diff --git a/src/Services/LogSystem/HistoryHelper.php b/src/Services/LogSystem/HistoryHelper.php index 0e3f7212..e1638f41 100644 --- a/src/Services/LogSystem/HistoryHelper.php +++ b/src/Services/LogSystem/HistoryHelper.php @@ -46,6 +46,7 @@ use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Part; +use App\Entity\ProjectSystem\Project; class HistoryHelper { @@ -81,6 +82,10 @@ class HistoryHelper $array = array_merge($array, $element->getParameters()->toArray()); } + if ($element instanceof Project) { + $array = array_merge($array, $element->getBomEntries()->toArray()); + } + return $array; } } diff --git a/src/Services/LogSystem/LogEntryExtraFormatter.php b/src/Services/LogSystem/LogEntryExtraFormatter.php index 4b19c103..e9bcf591 100644 --- a/src/Services/LogSystem/LogEntryExtraFormatter.php +++ b/src/Services/LogSystem/LogEntryExtraFormatter.php @@ -31,11 +31,13 @@ use App\Entity\LogSystem\ElementCreatedLogEntry; use App\Entity\LogSystem\ElementDeletedLogEntry; use App\Entity\LogSystem\ElementEditedLogEntry; use App\Entity\LogSystem\ExceptionLogEntry; -use App\Entity\LogSystem\InstockChangedLogEntry; +use App\Entity\LogSystem\LegacyInstockChangedLogEntry; +use App\Entity\LogSystem\PartStockChangedLogEntry; use App\Entity\LogSystem\SecurityEventLogEntry; use App\Entity\LogSystem\UserLoginLogEntry; use App\Entity\LogSystem\UserLogoutLogEntry; use App\Entity\LogSystem\UserNotAllowedLogEntry; +use App\Entity\Parts\PartLot; use App\Services\ElementTypeNameGenerator; use Symfony\Contracts\Translation\TranslatorInterface; @@ -155,7 +157,7 @@ class LogEntryExtraFormatter $array['log.element_edited.changed_fields'] = htmlspecialchars(implode(', ', $context->getChangedFields())); } - if ($context instanceof InstockChangedLogEntry) { + if ($context instanceof LegacyInstockChangedLogEntry) { $array[] = $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added'); $array[] = sprintf( '%s %s (%s)', @@ -179,6 +181,23 @@ class LogEntryExtraFormatter $array[] = htmlspecialchars($context->getMessage()); } + if ($context instanceof PartStockChangedLogEntry) { + $array['log.part_stock_changed.change'] = sprintf("%s %s %s (%s)", + $context->getOldStock(), + '', + $context->getNewStock(), + ($context->getNewStock() > $context->getOldStock() ? '+' : '-'). $context->getChangeAmount(), + ); + if (!empty($context->getComment())) { + $array['log.part_stock_changed.comment'] = htmlspecialchars($context->getComment()); + } + if ($context->getInstockChangeType() === PartStockChangedLogEntry::TYPE_MOVE) { + $array['log.part_stock_changed.move_target'] = + $this->elementTypeNameGenerator->getLocalizedTypeLabel(PartLot::class) + .' ' . $context->getMoveToTargetID(); + } + } + return $array; } } diff --git a/src/Services/Parts/PartLotWithdrawAddHelper.php b/src/Services/Parts/PartLotWithdrawAddHelper.php new file mode 100644 index 00000000..80403dd4 --- /dev/null +++ b/src/Services/Parts/PartLotWithdrawAddHelper.php @@ -0,0 +1,202 @@ +eventLogger = $eventLogger; + $this->eventCommentHelper = $eventCommentHelper; + } + + /** + * Checks whether the given part can + * @param PartLot $partLot + * @return bool + */ + public function canAdd(PartLot $partLot): bool + { + //We cannot add or withdraw parts from lots with unknown instock value. + if($partLot->isInstockUnknown()) { + return false; + } + + //So far all other restrictions are defined at the storelocation level + if($partLot->getStorageLocation() === null) { + return true; + } + + //We can not add parts if the storage location of the lot is marked as full + if($partLot->getStorageLocation()->isFull()) { + return false; + } + + return true; + } + + public function canWithdraw(PartLot $partLot): bool + { + //We cannot add or withdraw parts from lots with unknown instock value. + if ($partLot->isInstockUnknown()) { + return false; + } + + //Part must contain more than 0 parts + if ($partLot->getAmount() <= 0) { + return false; + } + + return true; + } + + /** + * Withdraw the specified amount of parts from the given part lot. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased) + * @param float $amount The amount of parts that should be taken from the part lot + * @param string|null $comment The optional comment describing the reason for the withdrawal + * @return PartLot The modified part lot + */ + public function withdraw(PartLot $partLot, float $amount, ?string $comment = null): PartLot + { + //Ensure that amount is positive + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + $part = $partLot->getPart(); + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $amount = round($amount); + } + + //Ensure that we can withdraw from the part lot + if (!$this->canWithdraw($partLot)) { + throw new \RuntimeException("Cannot withdraw from this part lot!"); + } + + //Ensure that there is enough stock to withdraw + if ($amount > $partLot->getAmount()) { + throw new \RuntimeException('Not enough stock to withdraw!'); + } + + //Subtract the amount from the part lot + $oldAmount = $partLot->getAmount(); + $partLot->setAmount($oldAmount - $amount); + + $event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment); + $this->eventLogger->log($event); + + //Apply the comment also to global events, so it gets associated with the elementChanged log entry + if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) { + $this->eventCommentHelper->setMessage($comment); + } + + return $partLot; + } + + /** + * Add the specified amount of parts to the given part lot. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased) + * @param float $amount The amount of parts that should be taken from the part lot + * @param string|null $comment The optional comment describing the reason for the withdrawal + * @return PartLot The modified part lot + */ + public function add(PartLot $partLot, float $amount, ?string $comment = null): PartLot + { + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + $part = $partLot->getPart(); + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $amount = round($amount); + } + + //Ensure that we can add to the part lot + if (!$this->canAdd($partLot)) { + throw new \RuntimeException("Cannot add to this part lot!"); + } + + $oldAmount = $partLot->getAmount(); + $partLot->setAmount($oldAmount + $amount); + + $event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment); + $this->eventLogger->log($event); + + //Apply the comment also to global events, so it gets associated with the elementChanged log entry + if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) { + $this->eventCommentHelper->setMessage($comment); + } + + return $partLot; + } + + /** + * Move the specified amount of parts from the given source part lot to the given target part lot. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $origin The part lot from which the parts should be taken + * @param PartLot $target The part lot to which the parts should be added + * @param float $amount The amount of parts that should be moved + * @param string|null $comment A comment describing the reason for the move + * @return void + */ + public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null): void + { + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + $part = $origin->getPart(); + + //Ensure that both part lots belong to the same part + if($origin->getPart() !== $target->getPart()) { + throw new \RuntimeException("Cannot move instock between different parts!"); + } + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $amount = round($amount); + } + + //Ensure that we can withdraw from origin and add to target + if (!$this->canWithdraw($origin) || !$this->canAdd($target)) { + throw new \RuntimeException("Cannot move instock between these part lots!"); + } + + //Ensure that there is enough stock to withdraw + if ($amount > $origin->getAmount()) { + throw new \RuntimeException('Not enough stock to withdraw!'); + } + + $oldOriginAmount = $origin->getAmount(); + + //Subtract the amount from the part lot + $origin->setAmount($origin->getAmount() - $amount); + //And add it to the target + $target->setAmount($target->getAmount() + $amount); + + $event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target); + $this->eventLogger->log($event); + + //Apply the comment also to global events, so it gets associated with the elementChanged log entry + if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) { + $this->eventCommentHelper->setMessage($comment); + } + } +} \ No newline at end of file diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 2022c496..2fde40b2 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -29,6 +29,8 @@ use App\Repository\DBElementRepository; use App\Repository\PartRepository; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Security; @@ -36,11 +38,13 @@ final class PartsTableActionHandler { private EntityManagerInterface $entityManager; private Security $security; + private UrlGeneratorInterface $urlGenerator; - public function __construct(EntityManagerInterface $entityManager, Security $security) + public function __construct(EntityManagerInterface $entityManager, Security $security, UrlGeneratorInterface $urlGenerator) { $this->entityManager = $entityManager; $this->security = $security; + $this->urlGenerator = $urlGenerator; } /** @@ -62,9 +66,21 @@ final class PartsTableActionHandler /** * @param Part[] $selected_parts + * @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null */ - public function handleAction(string $action, array $selected_parts, ?int $target_id): void + public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse { + if ($action === 'add_to_project') { + return new RedirectResponse( + $this->urlGenerator->generate('project_add_parts', [ + 'id' => $target_id, + 'parts' => implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)), + '_redirect' => $redirect_url + ]) + ); + } + + //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { if (!$part instanceof Part) { @@ -116,6 +132,8 @@ final class PartsTableActionHandler throw new InvalidArgumentException('The given action is unknown! ('.$action.')'); } } + + return null; } /** diff --git a/src/Services/ProjectSystem/ProjectBuildPartHelper.php b/src/Services/ProjectSystem/ProjectBuildPartHelper.php new file mode 100644 index 00000000..5ec1537b --- /dev/null +++ b/src/Services/ProjectSystem/ProjectBuildPartHelper.php @@ -0,0 +1,34 @@ +setBuiltProject($project); + + //Set the name of the part to the name of the project + $part->setName($project->getName()); + + //Set the description of the part to the description of the project + $part->setDescription($project->getDescription()); + + //Add a tag to the part that indicates that it is a build part + $part->setTags('project-build'); + + return $part; + } +} \ No newline at end of file diff --git a/src/Services/Tools/StatisticsHelper.php b/src/Services/Tools/StatisticsHelper.php index 9f54db80..60ed568d 100644 --- a/src/Services/Tools/StatisticsHelper.php +++ b/src/Services/Tools/StatisticsHelper.php @@ -43,7 +43,7 @@ namespace App\Services\Tools; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -111,7 +111,7 @@ class StatisticsHelper $arr = [ 'attachment_type' => AttachmentType::class, 'category' => Category::class, - 'device' => Device::class, + 'device' => Project::class, 'footprint' => Footprint::class, 'manufacturer' => Manufacturer::class, 'measurement_unit' => MeasurementUnit::class, diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index d94ccf20..d0384911 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -24,7 +24,7 @@ namespace App\Services\Trees; use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\PartAttachment; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -156,10 +156,10 @@ class ToolsTreeBuilder $this->urlGenerator->generate('category_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-tags'); } - if ($this->security->isGranted('read', new Device())) { + if ($this->security->isGranted('read', new Project())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.devices'), - $this->urlGenerator->generate('device_new') + $this->translator->trans('tree.tools.edit.projects'), + $this->urlGenerator->generate('project_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-archive'); } if ($this->security->isGranted('read', new Supplier())) { diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php index 8a413cc2..bc66ba47 100644 --- a/src/Services/Trees/TreeViewGenerator.php +++ b/src/Services/Trees/TreeViewGenerator.php @@ -25,7 +25,7 @@ namespace App\Services\Trees; use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\Base\AbstractStructuralDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -110,7 +110,7 @@ class TreeViewGenerator } if ($mode === 'devices') { - $href_type = ''; + $href_type = 'list_parts'; } $generic = $this->getGenericTree($class, $parent); @@ -161,8 +161,8 @@ class TreeViewGenerator return $this->translator->trans('manufacturer.labelp'); case Supplier::class: return $this->translator->trans('supplier.labelp'); - case Device::class: - return $this->translator->trans('device.labelp'); + case Project::class: + return $this->translator->trans('project.labelp'); default: return $this->translator->trans('tree.root_node.text'); } @@ -182,7 +182,7 @@ class TreeViewGenerator return $icon . 'fa-industry'; case Supplier::class: return $icon . 'fa-truck'; - case Device::class: + case Project::class: return $icon . 'fa-archive'; default: return null; diff --git a/src/Services/UserSystem/PermissionManager.php b/src/Services/UserSystem/PermissionManager.php index e0529269..717c0bac 100644 --- a/src/Services/UserSystem/PermissionManager.php +++ b/src/Services/UserSystem/PermissionManager.php @@ -77,6 +77,11 @@ class PermissionManager */ public function dontInherit(HasPermissionsInterface $user, string $permission, string $operation): ?bool { + //Check that the permission/operation combination is valid + if (! $this->isValidOperation($permission, $operation)) { + throw new InvalidArgumentException('The permission/operation combination "'.$permission.'/'.$operation.'" is not valid!'); + } + //Get the permissions from the user return $user->getPermissions()->getPermissionValue($permission, $operation); } diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index d2ea616f..20ae7248 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -102,6 +102,7 @@ class PermissionPresetsHelper //Set datastructures $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts_stock', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'categories', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'storelocations', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'footprints', PermissionData::ALLOW); @@ -110,6 +111,7 @@ class PermissionPresetsHelper $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'currencies', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'measurement_units', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'suppliers', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'projects', PermissionData::ALLOW); //Attachments permissions $this->permissionResolver->setPermission($permHolder, 'attachments', 'show_private', PermissionData::ALLOW); @@ -149,8 +151,8 @@ class PermissionPresetsHelper $this->permissionResolver->setPermission($perm_holder, 'labels', 'edit_options', PermissionData::ALLOW); $this->permissionResolver->setPermission($perm_holder, 'labels', 'read_profiles', PermissionData::ALLOW); - //Set devices permissions - $this->permissionResolver->setPermission($perm_holder, 'devices', 'read', PermissionData::ALLOW); + //Set projects permissions + $this->permissionResolver->setPermission($perm_holder, 'projects', 'read', PermissionData::ALLOW); return $perm_holder; } diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php new file mode 100644 index 00000000..e8ebc6d0 --- /dev/null +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -0,0 +1,148 @@ +. + */ + +namespace App\Services\UserSystem; + +use App\Entity\UserSystem\Group; +use App\Entity\UserSystem\PermissionData; +use App\Entity\UserSystem\User; +use App\Security\Interfaces\HasPermissionsInterface; + +class PermissionSchemaUpdater +{ + /** + * Check if the given user/group needs an update of its permission schema. + * @param HasPermissionsInterface $holder + * @return bool True if the permission schema needs an update, false otherwise. + */ + public function isSchemaUpdateNeeded(HasPermissionsInterface $holder): bool + { + $perm_data = $holder->getPermissions(); + + if ($perm_data->getSchemaVersion() < PermissionData::CURRENT_SCHEMA_VERSION) { + return true; + } + + return false; + } + + /** + * Upgrades the permission schema of the given user/group to the chosen version. + * Please note that this function does not flush the changes to DB! + * @param HasPermissionsInterface $holder + * @param int $target_version + * @return bool True, if an upgrade was done, false if it was not needed. + */ + public function upgradeSchema(HasPermissionsInterface $holder, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool + { + if ($target_version > PermissionData::CURRENT_SCHEMA_VERSION) { + throw new \InvalidArgumentException('The target version is higher than the maximum possible schema version!'); + } + + //Check if we need to do an update, if not, return false + if ($target_version <= $holder->getPermissions()->getSchemaVersion()) { + return false; + } + + //Do the update + for ($n = $holder->getPermissions()->getSchemaVersion(); $n < $target_version; ++$n) { + $reflectionClass = new \ReflectionClass(self::class); + try { + $method = $reflectionClass->getMethod('upgradeSchemaToVersion'.($n + 1)); + //Set the method accessible, so we can call it (needed for PHP < 8.1) + $method->setAccessible(true); + $method->invoke($this, $holder); + } catch (\ReflectionException $e) { + throw new \RuntimeException('Could not find update method for schema version '.($n + 1)); + } + + //Bump the schema version + $holder->getPermissions()->setSchemaVersion($n + 1); + } + + //When we end up here, we have done an upgrade and we can return true + return true; + } + + /** + * Upgrades the permission schema of the given group and all of its parent groups to the chosen version. + * Please note that this function does not flush the changes to DB! + * @param Group $group + * @param int $target_version + * @return bool True if an upgrade was done, false if it was not needed. + */ + public function groupUpgradeSchemaRecursively(Group $group, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool + { + $updated = $this->upgradeSchema($group, $target_version); + + /** @var Group $parent */ + $parent = $group->getParent(); + while ($parent) { + $updated = $this->upgradeSchema($parent, $target_version) || $updated; + $parent = $parent->getParent(); + } + + return $updated; + } + + /** + * Upgrades the permissions schema of the given users and its parent (including parent groups) to the chosen version. + * Please note that this function does not flush the changes to DB! + * @param User $user + * @param int $target_version + * @return bool True if an upgrade was done, false if it was not needed. + */ + public function userUpgradeSchemaRecursively(User $user, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool + { + $updated = $this->upgradeSchema($user, $target_version); + if ($user->getGroup()) { + $updated = $this->groupUpgradeSchemaRecursively($user->getGroup(), $target_version) || $updated; + } + + return $updated; + } + + private function upgradeSchemaToVersion1(HasPermissionsInterface $holder): void + { + //Use the part edit permission to set the preset value for the new part stock permission + if ( + !$holder->getPermissions()->isPermissionSet('parts_stock', 'withdraw') + && !$holder->getPermissions()->isPermissionSet('parts_stock', 'add') + && !$holder->getPermissions()->isPermissionSet('parts_stock', 'move') + ) { //Only do migration if the permission was not set already + + $new_value = $holder->getPermissions()->getPermissionValue('parts', 'edit'); + + $holder->getPermissions()->setPermissionValue('parts_stock', 'withdraw', $new_value); + $holder->getPermissions()->setPermissionValue('parts_stock', 'add', $new_value); + $holder->getPermissions()->setPermissionValue('parts_stock', 'move', $new_value); + } + } + + private function upgradeSchemaToVersion2(HasPermissionsInterface $holder): void + { + //If the projects permissions are not defined yet, rename devices permission to projects (just copy its data over) + if (!$holder->getPermissions()->isAnyOperationOfPermissionSet('projects')) { + $operations_value = $holder->getPermissions()->getAllDefinedOperationsOfPermission('devices'); + $holder->getPermissions()->setAllOperationsOfPermission('projects', $operations_value); + $holder->getPermissions()->removePermission('devices'); + } + } +} \ No newline at end of file diff --git a/src/Twig/EntityExtension.php b/src/Twig/EntityExtension.php index 5c0141ff..6d477d88 100644 --- a/src/Twig/EntityExtension.php +++ b/src/Twig/EntityExtension.php @@ -22,7 +22,7 @@ namespace App\Twig; use App\Entity\Attachments\Attachment; use App\Entity\Base\AbstractDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -99,7 +99,7 @@ final class EntityExtension extends AbstractExtension Storelocation::class => 'storelocation', Manufacturer::class => 'manufacturer', Category::class => 'category', - Device::class => 'device', + Project::class => 'device', Attachment::class => 'attachment', Supplier::class => 'supplier', User::class => 'user', diff --git a/src/Twig/FormatExtension.php b/src/Twig/FormatExtension.php index 08a4e85c..6d251267 100644 --- a/src/Twig/FormatExtension.php +++ b/src/Twig/FormatExtension.php @@ -24,7 +24,7 @@ namespace App\Twig; use App\Entity\Attachments\Attachment; use App\Entity\Base\AbstractDBElement; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; diff --git a/templates/AdminPages/DeviceAdmin.html.twig b/templates/AdminPages/DeviceAdmin.html.twig deleted file mode 100644 index 633d3bb2..00000000 --- a/templates/AdminPages/DeviceAdmin.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "AdminPages/EntityAdminBase.html.twig" %} - -{% block card_title %} - {% trans %}device.caption{% endtrans %} -{% endblock %} - -{% block edit_title %} - {% trans %}device.edit{% endtrans %}: {{ entity.name }} -{% endblock %} - -{% block new_title %} - {% trans %}device.new{% endtrans %} -{% endblock %} \ No newline at end of file diff --git a/templates/AdminPages/ProjectAdmin.html.twig b/templates/AdminPages/ProjectAdmin.html.twig new file mode 100644 index 00000000..384f91d2 --- /dev/null +++ b/templates/AdminPages/ProjectAdmin.html.twig @@ -0,0 +1,55 @@ +{% extends "AdminPages/EntityAdminBase.html.twig" %} + +{# @var entity App\Entity\ProjectSystem\Project #} + +{% block card_title %} + {% trans %}project.caption{% endtrans %} +{% endblock %} + +{% block edit_title %} + {% trans %}project.edit{% endtrans %}: {{ entity.name }} +{% endblock %} + +{% block new_title %} + {% trans %}project.new{% endtrans %} +{% endblock %} + +{% block additional_pills %} + +{% endblock %} + +{% block quick_links %} +
+
+ +
+
+{% endblock %} + +{% block additional_controls %} + {{ form_row(form.description) }} + {{ form_row(form.status) }} + {% if entity.id %} +
+ +
+ {% if entity.buildPart %} + {{ entity.buildPart.name }} + {% else %} + {% trans %}project.edit.associated_build_part.add{% endtrans %} + {% endif %} +

{% trans %}project.edit.associated_build.hint{% endtrans %}

+
+
+ {% endif %} + +{% endblock %} + +{% block additional_panes %} +
+ {% form_theme form.bom_entries with ['Form/collection_types_layout.html.twig'] %} + {{ form_errors(form.bom_entries) }} + {{ form_widget(form.bom_entries) }} +
+{% endblock %} \ No newline at end of file diff --git a/templates/Form/collection_types_layout.html.twig b/templates/Form/collection_types_layout.html.twig new file mode 100644 index 00000000..02b2090f --- /dev/null +++ b/templates/Form/collection_types_layout.html.twig @@ -0,0 +1,79 @@ +{% block project_bom_entry_collection_widget %} + {% import 'components/collection_type.macro.html.twig' as collection %} +
+ + + + {# expand button #} + + + + {# Remove button #} + + + + + {% for entry in form %} + {{ form_widget(entry) }} + {% endfor %} + +
{% trans %}project.bom.quantity{% endtrans %}{% trans %}project.bom.part{% endtrans %}{% trans %}project.bom.name{% endtrans %}
+ + +
+ +{% endblock %} + +{% block project_bom_entry_widget %} + {% set target_id = 'expand_row-' ~ form.vars.name %} + + {% import 'components/collection_type.macro.html.twig' as collection %} + + + + + + {{ form_errors(form.quantity) }} + {{ form_widget(form.quantity) }} + + + {{ form_errors(form.part) }} + {{ form_widget(form.part) }} + + + {{ form_errors(form.name) }} + {{ form_widget(form.name) }} + + + + {{ form_errors(form) }} + + + + + +
+ {{ form_row(form.mountnames) }} +
+ +
+
+ {{ form_widget(form.price) }} + {{ form_widget(form.priceCurrency, {'attr': {'class': 'selectpicker', 'data-controller': 'elements--selectpicker'}}) }} +
+ {{ form_errors(form.price) }} + {{ form_errors(form.priceCurrency) }} +
+
+ {{ form_row(form.comment) }} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/Form/extendedBootstrap4_layout.html.twig b/templates/Form/extendedBootstrap4_layout.html.twig index dbac4b10..a409d8d4 100644 --- a/templates/Form/extendedBootstrap4_layout.html.twig +++ b/templates/Form/extendedBootstrap4_layout.html.twig @@ -41,7 +41,7 @@
{{ form_widget(form.value) }} {% if form.prefix is defined %} - {{ form_widget(form.prefix, {'attr': {'class': 'form-select'}}) }} + {{ form_widget(form.prefix, {'attr': {'class': '', 'style': 'max-width: 40px;'}}) }} {% endif %} {% if unit is not empty %} @@ -124,3 +124,7 @@ {{- block("choice_widget_collapsed", "bootstrap_base_layout.html.twig") -}} {%- endblock choice_widget_collapsed -%} + +{% block part_select_widget %} + {{ form_widget(form.autocomplete) }} +{% endblock %} \ No newline at end of file diff --git a/templates/Parts/info/_extended_infos.html.twig b/templates/Parts/info/_extended_infos.html.twig index fec3d5c9..e0bb01d7 100644 --- a/templates/Parts/info/_extended_infos.html.twig +++ b/templates/Parts/info/_extended_infos.html.twig @@ -37,14 +37,20 @@ {{ part.iD }} - {# ID #} + {# IPN #} {% trans %}part.edit.ipn{% endtrans %} {{ part.ipn ?? 'part.ipn.not_defined'|trans }} {# Favorite status #} {% trans %}part.isFavorite{% endtrans %} - {{ helper.boolean(part.favorite) }} + {{ helper.boolean_badge(part.favorite) }} + + + {# Build status #} + {% trans %}part.is_build_part{% endtrans %} + {{ helper.boolean_badge(part.projectBuildPart) }} + {% if part.projectBuildPart %}({{ part.builtProject.name }}){% endif %} diff --git a/templates/Parts/info/_part_lots.html.twig b/templates/Parts/info/_part_lots.html.twig index c28dc2ac..5a7bc217 100644 --- a/templates/Parts/info/_part_lots.html.twig +++ b/templates/Parts/info/_part_lots.html.twig @@ -1,6 +1,8 @@ {% import "helper.twig" as helper %} {% import "LabelSystem/dropdown_macro.html.twig" as dropdown %} +{% include "Parts/info/_withdraw_modal.html.twig" %} + @@ -8,6 +10,7 @@ {# Tags row #} + {# Button row #} @@ -57,6 +60,31 @@ {% endif %} + diff --git a/templates/Parts/info/_projects.html.twig b/templates/Parts/info/_projects.html.twig new file mode 100644 index 00000000..7adb2182 --- /dev/null +++ b/templates/Parts/info/_projects.html.twig @@ -0,0 +1,35 @@ +{% import "components/attachments.macro.html.twig" as attachments %} +{% import "helper.twig" as helper %} + +
{% trans %}part_lots.storage_location{% endtrans %} {% trans %}part_lots.amount{% endtrans %}
+
+ + + +
+
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}
+ + + + + + + + + + + + {% for bom_entry in part.projectBomEntries %} + {# @var bom_entry App\Entity\Project\ProjectBOMEntry #} + + + {# Name #} + {# Description #} + + + + {% endfor %} + +
{% trans %}entity.info.name{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}project.bom.quantity{% endtrans %}{% trans %}project.bom.mountnames{% endtrans %}
{% if bom_entry.project.masterPictureAttachment is not null %}{{ attachments.attachment_icon(bom_entry.project.masterPictureAttachment, attachment_manager) }}{% endif %}{{ bom_entry.project.name }}{{ bom_entry.project.description|format_markdown }}{{ bom_entry.quantity | format_amount(part.partUnit) }}{% for tag in bom_entry.mountnames|split(',') %} + {{ tag | trim }} + {% endfor %}
+ + + + {% trans %}part.info.add_part_to_project{% endtrans %} + \ No newline at end of file diff --git a/templates/Parts/info/_sidebar.html.twig b/templates/Parts/info/_sidebar.html.twig index 65a7d576..9d6296df 100644 --- a/templates/Parts/info/_sidebar.html.twig +++ b/templates/Parts/info/_sidebar.html.twig @@ -4,6 +4,10 @@ {% trans with {'%timestamp%': timeTravel|format_datetime('short')} %}part.info.timetravel_hint{% endtrans %} {% endif %} +{% if part.projectBuildPart %} + {% trans %}part.info.projectBuildPart.hint{% endtrans %}: {{ part.builtProject.name }} +{% endif %} +
{{ helper.date_user_combination(part, true) }} diff --git a/templates/Parts/info/_tools.html.twig b/templates/Parts/info/_tools.html.twig index 5f73b056..2c76062b 100644 --- a/templates/Parts/info/_tools.html.twig +++ b/templates/Parts/info/_tools.html.twig @@ -51,4 +51,10 @@
-{{ dropdown.profile_dropdown('part', part.id) }} \ No newline at end of file +{{ dropdown.profile_dropdown('part', part.id) }} + + + + {% trans %}part.info.add_part_to_project{% endtrans %} + \ No newline at end of file diff --git a/templates/Parts/info/_withdraw_modal.html.twig b/templates/Parts/info/_withdraw_modal.html.twig new file mode 100644 index 00000000..79ae2ea2 --- /dev/null +++ b/templates/Parts/info/_withdraw_modal.html.twig @@ -0,0 +1,61 @@ + \ No newline at end of file diff --git a/templates/Parts/info/show_part_info.html.twig b/templates/Parts/info/show_part_info.html.twig index 021b6666..9df5e3f6 100644 --- a/templates/Parts/info/show_part_info.html.twig +++ b/templates/Parts/info/show_part_info.html.twig @@ -20,6 +20,9 @@ {% if timeTravel != null %} ({{ timeTravel | format_datetime('short') }}) {% endif %} + {% if part.projectBuildPart %} + ({{ entity_type_label(part.builtProject) }}: {{ part.builtProject.name }}) + {% endif %}
{% trans %}id.label{% endtrans %}: {{ part.id }} {% if part.ipn is not empty %}({{ part.ipn }}){% endif %}
@@ -91,6 +94,15 @@ {% trans %}vendor.partinfo.history{% endtrans %} + {% if part.projectBomEntries is not empty %} + + {% endif %}
{% endif %} +
+ {% include "Parts/info/_projects.html.twig" %} +
+
{% include "Parts/info/_history.html.twig" %}
diff --git a/templates/Projects/add_parts.html.twig b/templates/Projects/add_parts.html.twig new file mode 100644 index 00000000..7a7ae61f --- /dev/null +++ b/templates/Projects/add_parts.html.twig @@ -0,0 +1,22 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}project.add_parts_to_project{% endtrans %}{% endblock %} + +{% block card_title %} + + {% trans %}project.add_parts_to_project{% endtrans %}{% if project %}: {{ project.name }}{% endif %} +{% endblock %} + +{% block card_content %} + + {{ form_start(form) }} + + {{ form_row(form.project) }} + {% form_theme form.bom_entries with ['Form/collection_types_layout.html.twig'] %} + {{ form_widget(form.bom_entries) }} + + {{ form_row(form.submit) }} + + {{ form_end(form) }} + +{% endblock %} \ No newline at end of file diff --git a/templates/Projects/info/_bom.html.twig b/templates/Projects/info/_bom.html.twig new file mode 100644 index 00000000..42ffd015 --- /dev/null +++ b/templates/Projects/info/_bom.html.twig @@ -0,0 +1,11 @@ +{% import "components/datatables.macro.html.twig" as datatables %} + +
+ +{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'projects') }} + +
+ + {% trans %}project.info.bom_add_parts{% endtrans %} + \ No newline at end of file diff --git a/templates/Projects/info/_info.html.twig b/templates/Projects/info/_info.html.twig new file mode 100644 index 00000000..c2d36d67 --- /dev/null +++ b/templates/Projects/info/_info.html.twig @@ -0,0 +1,77 @@ +{% import "helper.twig" as helper %} + +
+
+
+
+ {% if project.masterPictureAttachment %} + + + + {% else %} + Part main image + {% endif %} +
+
+

{{ project.name }} + {# You need edit permission to use the edit button #} + {% if is_granted('edit', project) %} + + {% endif %} +

+
{{ project.description|format_markdown(true) }}
+ {% if project.buildPart %} +
{% trans %}project.edit.associated_build_part{% endtrans %}:
+ {{ project.buildPart.name }} + {% endif %} + +
+
+
+ + +
{# Sidebar panel with infos about last creation date, etc. #} +
+ + {{ helper.date_user_combination(project, true) }} + +
+ + {{ helper.date_user_combination(project, false) }} + +
+ +
+
+ {{ helper.project_status_to_badge(project.status) }} +
+
+
+
+ + + {{ project.bomEntries | length }} + {% trans %}project.info.bom_entries_count{% endtrans %} + +
+
+ {% if project.children is not empty %} +
+
+ + + {{ project.children | length }} + {% trans %}project.info.sub_projects_count{% endtrans %} + +
+
+ {% endif %} +
+ + {% if project.comment is not empty %} +

+

{% trans %}comment.label{% endtrans %}:
+ {{ project.comment|format_markdown }} +

+ {% endif %} +
\ No newline at end of file diff --git a/templates/Projects/info/_info_card.html.twig b/templates/Projects/info/_info_card.html.twig new file mode 100644 index 00000000..57494026 --- /dev/null +++ b/templates/Projects/info/_info_card.html.twig @@ -0,0 +1,133 @@ +{% import "helper.twig" as helper %} +{% import "LabelSystem/dropdown_macro.html.twig" as dropdown %} + +{{ helper.breadcrumb_entity_link(project) }} + +
+
+
+ +
+
+
+ {% if project.description is not empty %} + {{ project.description|format_markdown }} + {% endif %} +
+ +
+
+
+
+
+
+ + {{ project.name }} +
+
+ + + {% if project.parent %} + {{ project.parent.fullPath }} + {% else %} + - + {% endif %} + +
+
+
+ {% block quick_links %}{% endblock %} + + + {% trans %}entity.edit.btn{% endtrans %} + +
+ + {{ project.lastModified | format_datetime("short") }} + +
+ + {{ project.addedDate | format_datetime("short") }} + +
+
+
+
+
+
+
+ + {{ project.children | length }} +
+
+ + {{ project.bomEntries | length }} +
+
+
+ + {% if project.attachments is not empty %} +
+ {% include "Parts/info/_attachments_info.html.twig" with {"part": project} %} +
+ {% endif %} + + {% if project.parameters is not empty %} +
+ {% for name, parameters in project.groupedParameters %} + {% if name is not empty %}
{{ name }}
{% endif %} + {{ helper.parameters_table(project) }} + {% endfor %} +
+ {% endif %} + + {% if project.comment is not empty %} +
+
+ {{ project.comment|format_markdown }} +
+
+ {% endif %} +
+
+
+
+
+
+
\ No newline at end of file diff --git a/templates/Projects/info/_subprojects.html.twig b/templates/Projects/info/_subprojects.html.twig new file mode 100644 index 00000000..a7944646 --- /dev/null +++ b/templates/Projects/info/_subprojects.html.twig @@ -0,0 +1,28 @@ + + + + + + + + + + + {% for subproject in project.children %} + + + + + + + {% endfor %} + +
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}# {% trans %}project.info.bom_entries_count{% endtrans %}# {% trans %}project.info.sub_projects_count{% endtrans %}
{# Name #} + {{ subproject.name }} + {# Description #} + {{ subproject.description | format_markdown }} + + {{ subproject.bomEntries | length }} + + {{ subproject.children | length }} +
\ No newline at end of file diff --git a/templates/Projects/info/info.html.twig b/templates/Projects/info/info.html.twig new file mode 100644 index 00000000..ce920e9e --- /dev/null +++ b/templates/Projects/info/info.html.twig @@ -0,0 +1,95 @@ +{% extends "main_card.html.twig" %} +{% import "helper.twig" as helper %} + +{% block title %} + {% trans %}project.info.title{% endtrans %}: {{ project.name }} +{% endblock %} + +{% block content %} + + {{ helper.breadcrumb_entity_link(project) }} + {{ parent() }} +{% endblock %} + +{% block card_title %} + {% if project.masterPictureAttachment is not null and attachment_manager.isFileExisting(project.masterPictureAttachment) %} + + {% else %} + {{ helper.entity_icon(project, "me-1") }} + {% endif %} + {% trans %}project.info.title{% endtrans %}: {{ project.name }} +{% endblock %} + +{% block card_content %} + + +
+
+ {% include "Projects/info/_info.html.twig" %} +
+ {% if project.children is not empty %} +
+ {% include "Projects/info/_subprojects.html.twig" %} +
+ {% endif %} +
+ {% include "Projects/info/_bom.html.twig" %} +
+
+ {% include "Parts/info/_attachments_info.html.twig" with {"part": project} %} +
+
+ {% for name, parameters in project.groupedParameters %} + {% if name is not empty %}
{{ name }}
{% endif %} + {{ helper.parameters_table(project.parameters) }} + {% endfor %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/components/collection_type.macro.html.twig b/templates/components/collection_type.macro.html.twig index c97a8c56..5fcc8053 100644 --- a/templates/components/collection_type.macro.html.twig +++ b/templates/components/collection_type.macro.html.twig @@ -1,12 +1,14 @@ -{% macro controller(form, deleteMessage) %} +{% macro controller(form, deleteMessage, rowsToDelete) %} {% if form.vars.prototype is defined %} {{ stimulus_controller('elements/collection_type', { 'deleteMessage': deleteMessage|trans, - 'prototype': form_widget(form.vars.prototype)|e('html_attr') + 'prototype': form_widget(form.vars.prototype)|e('html_attr'), + 'rowsToDelete': rowsToDelete, }) }} {% else %} {# If add_element is disabled/forbidden, prototype is not available #} {{ stimulus_controller('elements/collection_type', { 'deleteMessage': deleteMessage|trans, + 'rowsToDelete': rowsToDelete }) }} {% endif %} {% endmacro %} diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index b1b9d14a..0c5c170c 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -48,6 +48,9 @@ + + + diff --git a/templates/components/tree_macros.html.twig b/templates/components/tree_macros.html.twig index db4ce4cb..b85fcc88 100644 --- a/templates/components/tree_macros.html.twig +++ b/templates/components/tree_macros.html.twig @@ -6,7 +6,7 @@ ['footprints', path('tree_footprint_root'), 'footprint.labelp', is_granted('@footprints.read') and is_granted('@parts.read')], ['manufacturers', path('tree_manufacturer_root'), 'manufacturer.labelp', is_granted('@manufacturers.read') and is_granted('@parts.read')], ['suppliers', path('tree_supplier_root'), 'supplier.labelp', is_granted('@suppliers.read') and is_granted('@parts.read')], - ['devices', path('tree_device_root'), 'device.labelp', is_granted('@devices.read')], + ['devices', path('tree_device_root'), 'project.labelp', is_granted('@projects.read')], ['tools', path('tree_tools'), 'tools.label', true], ] %} diff --git a/templates/helper.twig b/templates/helper.twig index 4cfe1a11..27f06f59 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -57,6 +57,21 @@ {% endif %} {% endmacro %} +{% macro project_status_to_badge(status, class="badge") %} + {% if status is not empty %} + {% set color = " bg-secondary" %} + + {% if status == "in_production" %} + {% set color = " bg-success" %} + {% endif %} + + + + {{ ("project.status." ~ status) | trans }} + + {% endif %} +{% endmacro %} + {% macro structural_entity_link(entity, link_type = "list_parts") %} {# @var entity \App\Entity\Base\StructuralDBElement #} {% if entity %} @@ -81,7 +96,7 @@ "attachment_type": ["fa-solid fa-file-alt", "attachment_type.label"], "category": ["fa-solid fa-tags", "category.label"], "currency": ["fa-solid fa-coins", "currency.label"], - "device": ["fa-solid fa-archive", "device.label"], + "device": ["fa-solid fa-archive", "project.label"], "footprint": ["fa-solid fa-microchip", "footprint.label"], "group": ["fa-solid fa-users", "group.label"], "label_profile": ["fa-solid fa-qrcode", "label_profile.label"], diff --git a/tests/ApplicationAvailabilityFunctionalTest.php b/tests/ApplicationAvailabilityFunctionalTest.php index b9ab07b1..18fac229 100644 --- a/tests/ApplicationAvailabilityFunctionalTest.php +++ b/tests/ApplicationAvailabilityFunctionalTest.php @@ -126,5 +126,10 @@ class ApplicationAvailabilityFunctionalTest extends WebTestCase //Webauthn Register yield ['/webauthn/register']; + + //Projects + yield ['/project/1/info']; + yield ['/project/1/add_parts']; + yield ['/project/1/add_parts?parts=1,2']; } } diff --git a/tests/Controller/AdminPages/AbstractAdminControllerTest.php b/tests/Controller/AdminPages/AbstractAdminControllerTest.php index 47718697..532f0e92 100644 --- a/tests/Controller/AdminPages/AbstractAdminControllerTest.php +++ b/tests/Controller/AdminPages/AbstractAdminControllerTest.php @@ -92,7 +92,7 @@ abstract class AbstractAdminControllerTest extends WebTestCase } //Test read/list access by access /new overview page - $client->request('GET', static::$base_path.'/1'); + $client->request('GET', static::$base_path.'/1/edit'); $this->assertFalse($client->getResponse()->isRedirect()); $this->assertSame($read, $client->getResponse()->isSuccessful(), 'Controller was not successful!'); $this->assertSame($read, !$client->getResponse()->isForbidden(), 'Permission Checking not working!'); diff --git a/tests/Controller/AdminPages/DeviceControllerTest.php b/tests/Controller/AdminPages/ProjectControllerTest.php similarity index 82% rename from tests/Controller/AdminPages/DeviceControllerTest.php rename to tests/Controller/AdminPages/ProjectControllerTest.php index 122d24ab..586c1b93 100644 --- a/tests/Controller/AdminPages/DeviceControllerTest.php +++ b/tests/Controller/AdminPages/ProjectControllerTest.php @@ -23,14 +23,14 @@ declare(strict_types=1); namespace App\Tests\Controller\AdminPages; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; /** * @group slow * @group DB */ -class DeviceControllerTest extends AbstractAdminControllerTest +class ProjectControllerTest extends AbstractAdminControllerTest { - protected static $base_path = '/en'.'/device'; - protected static $entity_class = Device::class; + protected static $base_path = '/en'.'/project'; + protected static $entity_class = Project::class; } diff --git a/tests/Entity/Attachments/AttachmentTest.php b/tests/Entity/Attachments/AttachmentTest.php index b6abfb7e..91a3cb53 100644 --- a/tests/Entity/Attachments/AttachmentTest.php +++ b/tests/Entity/Attachments/AttachmentTest.php @@ -27,7 +27,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentTypeAttachment; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; -use App\Entity\Attachments\DeviceAttachment; +use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; use App\Entity\Attachments\ManufacturerAttachment; @@ -36,7 +36,7 @@ use App\Entity\Attachments\PartAttachment; use App\Entity\Attachments\StorelocationAttachment; use App\Entity\Attachments\SupplierAttachment; use App\Entity\Attachments\UserAttachment; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -78,7 +78,7 @@ class AttachmentTest extends TestCase [AttachmentTypeAttachment::class, AttachmentType::class], [CategoryAttachment::class, Category::class], [CurrencyAttachment::class, Currency::class], - [DeviceAttachment::class, Device::class], + [ProjectAttachment::class, Project::class], [FootprintAttachment::class, Footprint::class], [GroupAttachment::class, Group::class], [ManufacturerAttachment::class, Manufacturer::class], @@ -117,8 +117,8 @@ class AttachmentTest extends TestCase /** @var Attachment $attachment */ $attachment = new $attachment_class(); - if (Device::class !== $allowed_class) { - $element = new Device(); + if (Project::class !== $allowed_class) { + $element = new Project(); } else { $element = new Category(); } diff --git a/tests/Entity/LogSystem/AbstractLogEntryTest.php b/tests/Entity/LogSystem/AbstractLogEntryTest.php index e2632873..fe4cdb58 100644 --- a/tests/Entity/LogSystem/AbstractLogEntryTest.php +++ b/tests/Entity/LogSystem/AbstractLogEntryTest.php @@ -44,8 +44,8 @@ namespace App\Tests\Entity\LogSystem; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\PartAttachment; -use App\Entity\Devices\Device; -use App\Entity\Devices\DevicePart; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\LogSystem\AbstractLogEntry; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -82,8 +82,8 @@ class AbstractLogEntryTest extends TestCase [2, Attachment::class], [3, AttachmentType::class], [4, Category::class], - [5, Device::class], - [6, DevicePart::class], + [5, Project::class], + [6, ProjectBOMEntry::class], [7, Footprint::class], [8, Group::class], [9, Manufacturer::class], diff --git a/tests/Entity/UserSystem/PermissionDataTest.php b/tests/Entity/UserSystem/PermissionDataTest.php index 131d31b6..5107c2a0 100644 --- a/tests/Entity/UserSystem/PermissionDataTest.php +++ b/tests/Entity/UserSystem/PermissionDataTest.php @@ -146,4 +146,73 @@ class PermissionDataTest extends TestCase $this->assertFalse($data->isPermissionSet('perm1', 'op2')); $this->assertFalse($data->isPermissionSet('perm1', 'op3')); } + + public function testGetSchemaVersion() + { + $data = new PermissionData(); + + //By default the schema version must be the CURRENT_SCHEMA_VERSION + $this->assertEquals(PermissionData::CURRENT_SCHEMA_VERSION, $data->getSchemaVersion()); + + //Ensure that the schema version can be set + $data->setSchemaVersion(12345); + $this->assertEquals(12345, $data->getSchemaVersion()); + } + + public function testIsAnyOperationOfPermissionSet() + { + $data = new PermissionData(); + + //Initially no operation of any permission is set + $this->assertFalse($data->isAnyOperationOfPermissionSet('perm1')); + + $data->setPermissionValue('perm1', 'op1', PermissionData::ALLOW); + $this->assertTrue($data->isAnyOperationOfPermissionSet('perm1')); + } + + public function testGetAllDefinedOperationsOfPermission() + { + $data = new PermissionData(); + + $this->assertEmpty($data->getAllDefinedOperationsOfPermission('perm1')); + + $data->setPermissionValue('perm1', 'op1', PermissionData::ALLOW); + $data->setPermissionValue('perm1', 'op2', PermissionData::DISALLOW); + + $this->assertEquals([ + 'op1' => PermissionData::ALLOW, 'op2' => PermissionData::DISALLOW, + ], + $data->getAllDefinedOperationsOfPermission('perm1')); + } + + public function testSetAllOperationsOfPermission() + { + $data = new PermissionData(); + + $data->setAllOperationsOfPermission('perm1', [ + 'op1' => PermissionData::ALLOW, + 'op2' => PermissionData::DISALLOW, + ]); + + $this->assertEquals([ + 'op1' => PermissionData::ALLOW, 'op2' => PermissionData::DISALLOW, + ], + $data->getAllDefinedOperationsOfPermission('perm1')); + } + + public function testRemovePermission() + { + $data = new PermissionData(); + + $data->setPermissionValue('perm1', 'op1', PermissionData::ALLOW); + $data->setPermissionValue('perm1', 'op2', PermissionData::DISALLOW); + + $this->assertTrue($data->isPermissionSet('perm1', 'op1')); + $this->assertTrue($data->isPermissionSet('perm1', 'op2')); + + $data->removePermission('perm1'); + + $this->assertFalse($data->isPermissionSet('perm1', 'op1')); + $this->assertFalse($data->isPermissionSet('perm1', 'op2')); + } } diff --git a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php new file mode 100644 index 00000000..b2f8a2f3 --- /dev/null +++ b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php @@ -0,0 +1,158 @@ +service = self::getContainer()->get(PartLotWithdrawAddHelper::class); + + $this->fillTestData(); + } + + private function fillTestData(): void + { + $this->part = new Part(); + + $this->storageLocation = new Storelocation(); + $this->full_storageLocation = new Storelocation(); + $this->full_storageLocation->setIsFull(true); + + $this->partLot1 = new TestPartLot(); + $this->partLot1->setPart($this->part); + $this->partLot1->setAmount(10); + + $this->partLot2 = new TestPartLot(); + $this->partLot2->setPart($this->part); + $this->partLot2->setStorageLocation($this->storageLocation); + $this->partLot2->setAmount(2); + + $this->partLot3 = new TestPartLot(); + $this->partLot3->setPart($this->part); + $this->partLot3->setAmount(0); + + $this->fullLot = new TestPartLot(); + $this->fullLot->setPart($this->part); + $this->fullLot->setAmount(45); + $this->fullLot->setStorageLocation($this->full_storageLocation); + + $this->lotWithUnknownInstock = new TestPartLot(); + $this->lotWithUnknownInstock->setPart($this->part); + $this->lotWithUnknownInstock->setAmount(5); + $this->lotWithUnknownInstock->setInstockUnknown(true); + $this->lotWithUnknownInstock->setStorageLocation($this->storageLocation); + } + + public function testCanWithdraw() + { + //Normal lots should be withdrawable + $this->assertTrue($this->service->canWithdraw($this->partLot1)); + $this->assertTrue($this->service->canWithdraw($this->partLot2)); + //Empty lots should not be withdrawable + $this->assertFalse($this->service->canWithdraw($this->partLot3)); + + //Full lots should be withdrawable + $this->assertTrue($this->service->canWithdraw($this->fullLot)); + //Lots with unknown instock should not be withdrawable + $this->assertFalse($this->service->canWithdraw($this->lotWithUnknownInstock)); + } + + public function testCanAdd() + { + //Normal lots should be addable + $this->assertTrue($this->service->canAdd($this->partLot1)); + $this->assertTrue($this->service->canAdd($this->partLot2)); + $this->assertTrue($this->service->canAdd($this->partLot3)); + + //Full lots should not be addable + $this->assertFalse($this->service->canAdd($this->fullLot)); + //Lots with unknown instock should not be addable + $this->assertFalse($this->service->canAdd($this->lotWithUnknownInstock)); + } + + public function testAdd() + { + //Add 5 to lot 1 + $this->service->add($this->partLot1, 5, "Test"); + $this->assertEquals(15, $this->partLot1->getAmount()); + + //Add 3.2 to lot 2 + $this->service->add($this->partLot2, 3.2, "Test"); + $this->assertEquals(5, $this->partLot2->getAmount()); + + //Add 1.5 to lot 3 + $this->service->add($this->partLot3, 1.5, "Test"); + $this->assertEquals(2, $this->partLot3->getAmount()); + + } + + public function testWithdraw() + { + //Withdraw 5 from lot 1 + $this->service->withdraw($this->partLot1, 5, "Test"); + $this->assertEquals(5, $this->partLot1->getAmount()); + + //Withdraw 2.2 from lot 2 + $this->service->withdraw($this->partLot2, 2.2, "Test"); + $this->assertEquals(0, $this->partLot2->getAmount()); + } + + public function testMove() + { + //Move 5 from lot 1 to lot 2 + $this->service->move($this->partLot1, $this->partLot2, 5, "Test"); + $this->assertEquals(5, $this->partLot1->getAmount()); + $this->assertEquals(7, $this->partLot2->getAmount()); + + //Move 2.2 from lot 2 to lot 3 + $this->service->move($this->partLot2, $this->partLot3, 2.2, "Test"); + $this->assertEquals(5, $this->partLot2->getAmount()); + $this->assertEquals(2, $this->partLot3->getAmount()); + } +} diff --git a/tests/Services/UserSystem/PermissionManagerTest.php b/tests/Services/UserSystem/PermissionManagerTest.php index cca721ce..78ce2850 100644 --- a/tests/Services/UserSystem/PermissionManagerTest.php +++ b/tests/Services/UserSystem/PermissionManagerTest.php @@ -165,12 +165,6 @@ class PermissionManagerTest extends WebTestCase //Check for inherit from group $this->assertTrue($this->service->inherit($this->user, 'parts', 'show_history')); $this->assertFalse($this->service->inherit($this->user, 'parts', 'delete')); - $this->assertNull($this->service->inherit($this->user, 'parts', 'search')); - - //Check for inherit from group and parent group - $this->assertTrue($this->service->inherit($this->user, 'parts', 'all_parts')); - $this->assertFalse($this->service->inherit($this->user, 'parts', 'no_price_parts')); - $this->assertNull($this->service->inherit($this->user, 'parts', 'obsolete_parts')); //Test for user without group $this->assertTrue($this->service->inherit($this->user_withoutGroup, 'parts', 'read')); diff --git a/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php new file mode 100644 index 00000000..79abb89c --- /dev/null +++ b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php @@ -0,0 +1,116 @@ +. + */ + +namespace App\Tests\Services\UserSystem; + +use App\Entity\UserSystem\PermissionData; +use App\Security\Interfaces\HasPermissionsInterface; +use App\Services\UserSystem\PermissionSchemaUpdater; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +class TestPermissionHolder implements HasPermissionsInterface +{ + private PermissionData $perm_data; + + public function __construct(PermissionData $perm_data) + { + $this->perm_data = $perm_data; + } + + public function getPermissions(): PermissionData + { + return $this->perm_data; + } +} + +class PermissionSchemaUpdaterTest extends WebTestCase +{ + /** + * @var PermissionSchemaUpdater + */ + protected $service; + + public function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + $this->service = self::$container->get(PermissionSchemaUpdater::class); + } + + public function testIsSchemaUpdateNeeded() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(0); + $user = new TestPermissionHolder($perm_data); + + //With schema version 0, an update should be needed + self::assertTrue($this->service->isSchemaUpdateNeeded($user)); + + //With a very high scheme number no update should be needed + $perm_data->setSchemaVersion(123456); + self::assertFalse($this->service->isSchemaUpdateNeeded($user)); + } + + public function testUpgradeSchema() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(0); + $user = new TestPermissionHolder($perm_data); + + //With schema version 0, an update should be done and the schema version should be updated + self::assertTrue($this->service->upgradeSchema($user)); + self::assertEquals(PermissionData::CURRENT_SCHEMA_VERSION, $user->getPermissions()->getSchemaVersion()); + + //If we redo it with the same schema version, no update should be done + self::assertFalse($this->service->upgradeSchema($user)); + } + + public function testUpgradeSchemaToVersion1() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(0); + $perm_data->setPermissionValue('parts', 'edit', PermissionData::ALLOW); + $user = new TestPermissionHolder($perm_data); + + //Do an upgrade and afterwards the move, add, and withdraw permissions should be set to ALLOW + self::assertTrue($this->service->upgradeSchema($user, 1)); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('parts_stock', 'move')); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('parts_stock', 'add')); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('parts_stock', 'withdraw')); + } + + public function testUpgradeSchemaToVersion2() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(1); + $perm_data->setPermissionValue('devices', 'read', PermissionData::ALLOW); + $perm_data->setPermissionValue('devices', 'edit', PermissionData::INHERIT); + $perm_data->setPermissionValue('devices', 'delete', PermissionData::DISALLOW); + $user = new TestPermissionHolder($perm_data); + + //After the upgrade all operations should be available under the name "projects" with the same values + self::assertTrue($this->service->upgradeSchema($user, 2)); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('projects', 'read')); + self::assertEquals(PermissionData::INHERIT, $user->getPermissions()->getPermissionValue('projects', 'edit')); + self::assertEquals(PermissionData::DISALLOW, $user->getPermissions()->getPermissionValue('projects', 'delete')); + } +} diff --git a/tests/Twig/EntityExtensionTest.php b/tests/Twig/EntityExtensionTest.php index b9124658..9e951ea6 100644 --- a/tests/Twig/EntityExtensionTest.php +++ b/tests/Twig/EntityExtensionTest.php @@ -22,7 +22,7 @@ namespace App\Tests\Twig; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\PartAttachment; -use App\Entity\Devices\Device; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -58,7 +58,7 @@ class EntityExtensionTest extends WebTestCase $this->assertEquals('storelocation', $this->service->getEntityType(new Storelocation())); $this->assertEquals('manufacturer', $this->service->getEntityType(new Manufacturer())); $this->assertEquals('category', $this->service->getEntityType(new Category())); - $this->assertEquals('device', $this->service->getEntityType(new Device())); + $this->assertEquals('device', $this->service->getEntityType(new Project())); $this->assertEquals('attachment', $this->service->getEntityType(new PartAttachment())); $this->assertEquals('supplier', $this->service->getEntityType(new Supplier())); $this->assertEquals('user', $this->service->getEntityType(new User())); diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index bf2618bc..aadc374c 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -147,35 +147,35 @@ Neue Währung - + Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:4 Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:4 templates\AdminPages\DeviceAdmin.html.twig:4 - device.caption - Baugruppen + project.caption + Projekte - + Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:8 new - device.edit - Bearbeite Baugruppe + project.edit + Bearbeite Projekt - + Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:12 new - device.new - Neue Baugruppe + project.new + Neues Projekt @@ -4094,7 +4094,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Los! - + Part-DB1\templates\_sidebar.html.twig:37 Part-DB1\templates\_sidebar.html.twig:12 @@ -4106,8 +4106,8 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr templates\base.html.twig:230 - device.labelp - Baugruppen + project.labelp + Projekte @@ -6024,14 +6024,14 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Ahangstyp - + Part-DB1\src\Services\ElementTypeNameGenerator.php:82 Part-DB1\src\Services\ElementTypeNameGenerator.php:82 - device.label - Baugruppe + project.label + Projekt @@ -6214,15 +6214,15 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Kategorien - + Part-DB1\src\Services\Trees\ToolsTreeBuilder.php:161 Part-DB1\src\Services\Trees\ToolsTreeBuilder.php:138 src\Services\ToolsTreeBuilder.php:66 - tree.tools.edit.devices - Baugruppen + tree.tools.edit.projects + Projekte @@ -8111,14 +8111,14 @@ Element 3 Hersteller - + obsolete obsolete - perm.part.devices - Baugruppen + perm.projects + Projekte @@ -8501,16 +8501,6 @@ Element 3 System - - - obsolete - obsolete - - - perm.device_parts - Baugruppenbauteile - - obsolete @@ -9754,12 +9744,6 @@ Element 3 [ALT] Bestand geändert - - - device_part.label - Projektteil - - log.target_id diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 6ee35efc..d66f855a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -147,35 +147,35 @@ New currency - + Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:4 Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:4 templates\AdminPages\DeviceAdmin.html.twig:4 - device.caption - Device + project.caption + Project - + Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:8 new - device.edit - Edit device + project.edit + Edit project - + Part-DB1\templates\AdminPages\DeviceAdmin.html.twig:12 new - device.new - New device + project.new + New project @@ -3284,7 +3284,7 @@ Sub elements will be moved upwards.]]> statistics.devices_count - Number of devices + Number of projects @@ -4095,7 +4095,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Go! - + Part-DB1\templates\_sidebar.html.twig:37 Part-DB1\templates\_sidebar.html.twig:12 @@ -4107,8 +4107,8 @@ If you have done this incorrectly or if a computer is no longer trusted, you can templates\base.html.twig:230 - device.labelp - Devices + project.labelp + Projects @@ -6025,13 +6025,13 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Attachment type - + Part-DB1\src\Services\ElementTypeNameGenerator.php:82 Part-DB1\src\Services\ElementTypeNameGenerator.php:82 - device.label + project.label Project @@ -6215,15 +6215,15 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Categories - + Part-DB1\src\Services\Trees\ToolsTreeBuilder.php:161 Part-DB1\src\Services\Trees\ToolsTreeBuilder.php:138 src\Services\ToolsTreeBuilder.php:66 - tree.tools.edit.devices - Devices + tree.tools.edit.projects + Projects @@ -8112,14 +8112,14 @@ Element 3 Manufacturers - + obsolete obsolete - perm.part.devices - Devices + perm.projects + Projects @@ -8502,16 +8502,6 @@ Element 3 System - - - obsolete - obsolete - - - perm.device_parts - Device parts - - obsolete @@ -8693,7 +8683,7 @@ Element 3 - + tfa.provider.webauthn_two_factor_provider Security key @@ -9755,12 +9745,6 @@ Element 3 [LEGACY] Instock changed - - - device_part.label - Device part - - log.target_id @@ -9768,184 +9752,502 @@ Element 3 - + entity.info.parts_count_recursive Number of parts with this element or its subelements - + tools.server_infos.title Server Infos - + permission.preset.read_only Read-Only - + permission.preset.read_only.desc Only allow read operations on data - + permission.preset.all_inherit Inherit all - + permission.preset.all_inherit.desc Set all permissions to Inherit - + permission.preset.all_forbid Forbid all - + permission.preset.all_forbid.desc Set all permissions to Forbid - + permission.preset.all_allow Allow all - + permission.preset.all_allow.desc Set all permissions to allow - + perm.server_infos Server infos - + permission.preset.editor Editor - + permission.preset.editor.desc Allow to change parts and data structures - + permission.preset.admin Admin - + permission.preset.admin.desc Allow administrative actions - + permission.preset.button Apply preset - + perm.attachments.show_private Show private attachments - + perm.attachments.list_attachments Show list of all attachments - + user.edit.permission_success Permission preset applied successfully. Check if the new permissions fit your needs. - + perm.group.data Data - + part_list.action.action.group.needs_review Needs Review - + part_list.action.action.set_needs_review Set Needs Review Status - + part_list.action.action.unset_needs_review Unset Needs Review Status - + part.edit.ipn Internal Part Number (IPN) - + part.ipn.not_defined Not defined - + part.table.ipn IPN - + currency.edit.update_rate Retrieve exchange rate - + currency.edit.exchange_rate_update.unsupported_currency The currency is unsupported by the exchange rate provider. Check your exchange rate provider configuration. - + currency.edit.exchange_rate_update.generic_error Unable to retrieve the exchange rate. Check your exchange rate provider configuration. - + currency.edit.exchange_rate_updated.success Retrieved the exchange rate successfully. + + + project.bom.quantity + BOM Qty. + + + + + project.bom.mountnames + Mount names + + + + + project.bom.name + Name + + + + + project.bom.comment + Notes + + + + + project.bom.part + Part + + + + + project.bom.add_entry + Add entry + + + + + part_list.action.group.projects + Projects + + + + + part_list.action.projects.add_to_project + Add parts to project + + + + + project.bom.delete.confirm + Do you really want to delete this BOM entry? + + + + + project.add_parts_to_project + Add parts to project BOM + + + + + part.info.add_part_to_project + Add this part to a project + + + + + project_bom_entry.label + BOM entry + + + + + project.edit.status + Project status + + + + + project.status.draft + Draft + + + + + project.status.planning + Planning + + + + + project.status.in_production + In production + + + + + project.status.finished + Finished + + + + + project.status.archived + Archived + + + + + part.new_build_part.error.build_part_already_exists + The project already has an build part! + + + + + project.edit.associated_build_part + Associated builds part + + + + + project.edit.associated_build_part.add + Add builds part + + + + + project.edit.associated_build.hint + This part represents the builds of this project, which are stored somewhere. + + + + + part.info.projectBuildPart.hint + This part represents the builds of the following project and is associated with it + + + + + part.is_build_part + Is project builds part + + + + + project.info.title + Project info + + + + + project.info.bom_entries_count + BOM entries + + + + + project.info.sub_projects_count + Subprojects + + + + + project.info.bom_add_parts + Add BOM entries + + + + + project.info.info.label + Info + + + + + project.info.sub_projects.label + Subprojects + + + + + project.bom.price + Price + + + + + part.info.withdraw_modal.title.withdraw + Withdraw parts from lot + + + + + part.info.withdraw_modal.title.add + Add parts to lot + + + + + part.info.withdraw_modal.title.move + Move parts from lot to another lot + + + + + part.info.withdraw_modal.amount + Amount + + + + + part.info.withdraw_modal.move_to + Move to + + + + + part.info.withdraw_modal.comment + Comment + + + + + part.info.withdraw_modal.comment.hint + You can set a comment here describing why you are doing this operation (e.g. for what you need the parts). This info will be saved in the log. + + + + + modal.close + Close + + + + + modal.submit + Submit + + + + + part.withdraw.success + Added/Moved/Withdrawn parts successfully. + + + + + perm.parts_stock + Parts Stock + + + + + perm.parts_stock.withdraw + Withdraw parts from stock + + + + + perm.parts_stock.add + Add parts to stock + + + + + perm.parts_stock.move + Move parts between lots + + + + + user.permissions_schema_updated + The permission schema of your user were upgraded to the latest version. + + + + + log.type.part_stock_changed + Part Stock changed + + + + + log.part_stock_changed.withdraw + Stock withdrawn + + + + + log.part_stock_changed.add + Stock added + + + + + log.part_stock_changed.move + Stock moved + + + + + log.part_stock_changed.comment + Comment + + + + + log.part_stock_changed.change + Change + + + + + log.part_stock_changed.move_target + Move target + + diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index e9c3e6dc..c13aff31 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -8503,16 +8503,6 @@ exemple de ville Système - - - obsolete - obsolete - - - perm.device_parts - Composants de projet - - obsolete diff --git a/translations/messages.ja.xlf b/translations/messages.ja.xlf index a6af9506..2800df05 100644 --- a/translations/messages.ja.xlf +++ b/translations/messages.ja.xlf @@ -8504,16 +8504,6 @@ Exampletown システム - - - obsolete - obsolete - - - perm.device_parts -  デバイスの部品 - - obsolete diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf index dc141893..039092ff 100644 --- a/translations/messages.ru.xlf +++ b/translations/messages.ru.xlf @@ -8505,16 +8505,6 @@ Система - - - obsolete - obsolete - - - perm.device_parts - Компоненты устройства - - obsolete diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index f425756a..cc38990f 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -239,5 +239,47 @@ The internal part number must be unique. {{ value }} is already in use! + + + validator.project.bom_entry.name_or_part_needed + You have to choose a part for a part BOM entry or set a name for a non-part BOM entry. + + + + + project.bom_entry.name_already_in_bom + There is already an BOM entry with this name! + + + + + project.bom_entry.part_already_in_bom + This part already exists in the BOM! + + + + + project.bom_entry.mountnames_quantity_mismatch + The number of mountnames has to match the BOMs quantity! + + + + + project.bom_entry.can_not_add_own_builds_part + You can not add a project's own builds part to the BOM. + + + + + project.bom_has_to_include_all_subelement_parts + The project BOM has to include all subprojects builds parts. Part %part_name% of project %project_name% missing! + + + + + project.bom_entry.price_not_allowed_on_parts + Prices are not allowed on BOM entries associated with a part. Define the price on the part instead. + +