mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-15 14:09:36 +00:00
Merge branch 'gtin'
This commit is contained in:
commit
7a83581597
71 changed files with 1405 additions and 92 deletions
|
|
@ -74,16 +74,24 @@ export default class extends Controller {
|
|||
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
|
||||
|
||||
|
||||
let ret = null;
|
||||
|
||||
//Insert new html after the last child element
|
||||
//If the table has a tbody, insert it there
|
||||
//Afterwards return the newly created row
|
||||
if(targetTable.tBodies[0]) {
|
||||
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
|
||||
return targetTable.tBodies[0].lastElementChild;
|
||||
ret = targetTable.tBodies[0].lastElementChild;
|
||||
} else { //Otherwise just insert it
|
||||
targetTable.insertAdjacentHTML('beforeend', newElementStr);
|
||||
return targetTable.lastElementChild;
|
||||
ret = targetTable.lastElementChild;
|
||||
}
|
||||
|
||||
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
|
||||
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
|
||||
|
||||
return ret;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {Controller} from "@hotwired/stimulus";
|
||||
import {Modal} from "bootstrap";
|
||||
|
||||
export default class extends Controller
|
||||
{
|
||||
connect() {
|
||||
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
|
||||
}
|
||||
|
||||
_handleModalOpen(event) {
|
||||
// Button that triggered the modal
|
||||
const button = event.relatedTarget;
|
||||
|
||||
const amountInput = this.element.querySelector('input[name="amount"]');
|
||||
|
||||
// Extract info from button attributes
|
||||
const lotID = button.getAttribute('data-lot-id');
|
||||
const lotAmount = button.getAttribute('data-lot-amount');
|
||||
|
||||
//Find the expected amount field and set the value to the lot amount
|
||||
const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount');
|
||||
expectedAmountInput.textContent = lotAmount;
|
||||
|
||||
//Set the action and lotID inputs in the form
|
||||
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,8 @@ class TristateHelper {
|
|||
|
||||
document.addEventListener("turbo:load", listener);
|
||||
document.addEventListener("turbo:render", listener);
|
||||
document.addEventListener("collection:elementAdded", listener);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TristateHelper();
|
||||
export default new TristateHelper();
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@
|
|||
"symfony/yaml": "7.4.*",
|
||||
"symplify/easy-coding-standard": "^12.5.20",
|
||||
"tecnickcom/tc-lib-barcode": "^2.1.4",
|
||||
"tiendanube/gtinvalidation": "^1.0",
|
||||
"twig/cssinliner-extra": "^3.0",
|
||||
"twig/extra-bundle": "^3.8",
|
||||
"twig/html-extra": "^3.8",
|
||||
|
|
|
|||
50
composer.lock
generated
50
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "7ca9c95fb85f6bf3d9b8a3aa98ca33f6",
|
||||
"content-hash": "b47c8579efa64349c5e65296e2a44206",
|
||||
"packages": [
|
||||
{
|
||||
"name": "amphp/amp",
|
||||
|
|
@ -16830,6 +16830,54 @@
|
|||
],
|
||||
"time": "2025-05-14T06:15:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tiendanube/gtinvalidation",
|
||||
"version": "v1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/TiendaNube/GtinValidator.git",
|
||||
"reference": "7ff5794b6293eb748bf1efcddf4e20a657c31855"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/TiendaNube/GtinValidator/zipball/7ff5794b6293eb748bf1efcddf4e20a657c31855",
|
||||
"reference": "7ff5794b6293eb748bf1efcddf4e20a657c31855",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "4.6.*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GtinValidation\\": "src/GtinValidation"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ryan Blakemore",
|
||||
"homepage": "https://github.com/ryanblak",
|
||||
"role": "developer"
|
||||
}
|
||||
],
|
||||
"description": "Validates GTIN product codes.",
|
||||
"keywords": [
|
||||
"gtin",
|
||||
"product codes"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/TiendaNube/GtinValidator/issues",
|
||||
"source": "https://github.com/TiendaNube/GtinValidator/tree/master"
|
||||
},
|
||||
"time": "2018-07-25T22:31:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
|||
move:
|
||||
label: "perm.parts_stock.move"
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
stocktake:
|
||||
label: "perm.parts_stock.stocktake"
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
|
||||
|
||||
storelocations: &PART_CONTAINING
|
||||
|
|
|
|||
129
migrations/Version20260208131116.php
Normal file
129
migrations/Version20260208131116.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
|
||||
final class Version20260208131116 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add GTIN fields, allowed targets for attachment types and last stocktake date for part lots and add include_vat field for price details.';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets LONGTEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat TINYINT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE `attachment_types` DROP allowed_targets');
|
||||
$this->addSql('DROP INDEX parts_idx_gtin ON `parts`');
|
||||
$this->addSql('ALTER TABLE `parts` DROP gtin');
|
||||
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
|
||||
$this->addSql('ALTER TABLE `orderdetails` DROP prices_includes_vat');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE attachment_types ADD COLUMN allowed_targets CLOB DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD COLUMN last_stocktake_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts');
|
||||
$this->addSql('DROP TABLE parts');
|
||||
$this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, gtin VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts');
|
||||
$this->addSql('DROP TABLE __temp__parts');
|
||||
$this->addSql('CREATE INDEX parts_idx_name ON parts (name)');
|
||||
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
|
||||
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD COLUMN prices_includes_vat BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM "attachment_types"');
|
||||
$this->addSql('DROP TABLE "attachment_types"');
|
||||
$this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, filetype_filter CLOB NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "attachment_types" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM __temp__attachment_types');
|
||||
$this->addSql('DROP TABLE __temp__attachment_types');
|
||||
$this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)');
|
||||
$this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)');
|
||||
$this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"');
|
||||
$this->addSql('DROP TABLE "parts"');
|
||||
$this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts');
|
||||
$this->addSql('DROP TABLE __temp__parts');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)');
|
||||
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)');
|
||||
$this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)');
|
||||
$this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM "orderdetails"');
|
||||
$this->addSql('DROP TABLE "orderdetails"');
|
||||
$this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "orderdetails" (id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier) SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM __temp__orderdetails');
|
||||
$this->addSql('DROP TABLE __temp__orderdetails');
|
||||
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)');
|
||||
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)');
|
||||
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "attachment_types" DROP allowed_targets');
|
||||
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
|
||||
$this->addSql('DROP INDEX parts_idx_gtin');
|
||||
$this->addSql('ALTER TABLE "parts" DROP gtin');
|
||||
$this->addSql('ALTER TABLE "orderdetails" DROP prices_includes_vat');
|
||||
}
|
||||
}
|
||||
|
|
@ -54,12 +54,14 @@ use Exception;
|
|||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
|
@ -463,6 +465,54 @@ final class PartController extends AbstractController
|
|||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])]
|
||||
#[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')]
|
||||
public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||
Request $request,
|
||||
): Response
|
||||
{
|
||||
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
|
||||
|
||||
//Check that the user is allowed to stocktake the partlot
|
||||
$this->denyAccessUnlessGranted('stocktake', $partLot);
|
||||
|
||||
if (!$partLot instanceof PartLot) {
|
||||
throw new \RuntimeException('Part lot not found!');
|
||||
}
|
||||
//Ensure that the partlot belongs to the part
|
||||
if ($partLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The origin partlot does not belong to the part!");
|
||||
}
|
||||
|
||||
$actualAmount = (float) $request->request->get('actual_amount');
|
||||
$comment = $request->request->get('comment');
|
||||
|
||||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if ($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
$withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp);
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
//Save the changes to the DB
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'part.withdraw.success');
|
||||
|
||||
//If a redirect was passed, then redirect there
|
||||
if ($request->request->get('_redirect')) {
|
||||
return $this->redirect($request->request->get('_redirect'));
|
||||
}
|
||||
//Otherwise just redirect to the part page
|
||||
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
|
||||
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
|
||||
{
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class PartFilter implements FilterInterface
|
|||
public readonly BooleanConstraint $favorite;
|
||||
public readonly BooleanConstraint $needsReview;
|
||||
public readonly NumberConstraint $mass;
|
||||
public readonly TextConstraint $gtin;
|
||||
public readonly DateTimeConstraint $lastModified;
|
||||
public readonly DateTimeConstraint $addedDate;
|
||||
public readonly EntityConstraint $category;
|
||||
|
|
@ -132,6 +133,7 @@ class PartFilter implements FilterInterface
|
|||
$this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit');
|
||||
$this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState');
|
||||
$this->mass = new NumberConstraint('part.mass');
|
||||
$this->gtin = new TextConstraint('part.gtin');
|
||||
$this->dbId = new IntConstraint('part.id');
|
||||
$this->ipn = new TextConstraint('part.ipn');
|
||||
$this->addedDate = new DateTimeConstraint('part.addedDate');
|
||||
|
|
|
|||
|
|
@ -218,6 +218,10 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
'label' => $this->translator->trans('part.table.mass'),
|
||||
'unit' => 'g'
|
||||
])
|
||||
->add('gtin', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.gtin'),
|
||||
'orderField' => 'NATSORT(part.gtin)'
|
||||
])
|
||||
->add('tags', TagsColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.tags'),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ use function in_array;
|
|||
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
|
||||
abstract class Attachment extends AbstractNamedDBElement
|
||||
{
|
||||
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
'AttachmentType' => AttachmentTypeAttachment::class,
|
||||
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
|
||||
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
|
||||
|
|
@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
|||
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
|
||||
* @phpstan-var class-string<T>
|
||||
*/
|
||||
protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
|
||||
public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
|
||||
|
||||
/**
|
||||
* @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.
|
||||
|
|
|
|||
|
|
@ -134,6 +134,17 @@ class AttachmentType extends AbstractStructuralDBElement
|
|||
#[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)]
|
||||
protected Collection $attachments_with_type;
|
||||
|
||||
/**
|
||||
* @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names
|
||||
*/
|
||||
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
|
||||
protected ?array $allowed_targets = null;
|
||||
|
||||
/**
|
||||
* @var class-string<Attachment>[]|null
|
||||
*/
|
||||
protected ?array $allowed_targets_parsed_cache = null;
|
||||
|
||||
#[Groups(['attachment_type:read'])]
|
||||
protected ?\DateTimeImmutable $addedDate = null;
|
||||
#[Groups(['attachment_type:read'])]
|
||||
|
|
@ -184,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions.
|
||||
* @return class-string<Attachment>[]|null
|
||||
*/
|
||||
public function getAllowedTargets(): ?array
|
||||
{
|
||||
//Use cached value if available
|
||||
if ($this->allowed_targets_parsed_cache !== null) {
|
||||
return $this->allowed_targets_parsed_cache;
|
||||
}
|
||||
|
||||
if (empty($this->allowed_targets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
foreach ($this->allowed_targets as $target) {
|
||||
if (isset(Attachment::ORM_DISCRIMINATOR_MAP[$target])) {
|
||||
$tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target];
|
||||
}
|
||||
//Otherwise ignore the entry, as it is invalid
|
||||
}
|
||||
|
||||
//Cache the parsed value
|
||||
$this->allowed_targets_parsed_cache = $tmp;
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions.
|
||||
* @param class-string<Attachment>[]|null $allowed_targets
|
||||
* @return $this
|
||||
*/
|
||||
public function setAllowedTargets(?array $allowed_targets): self
|
||||
{
|
||||
if ($allowed_targets === null) {
|
||||
$this->allowed_targets = null;
|
||||
} else {
|
||||
$tmp = [];
|
||||
foreach ($allowed_targets as $target) {
|
||||
$discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true);
|
||||
if ($discriminator !== false) {
|
||||
$tmp[] = $discriminator;
|
||||
} else {
|
||||
throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass.");
|
||||
}
|
||||
}
|
||||
$this->allowed_targets = $tmp;
|
||||
}
|
||||
|
||||
//Reset the cache
|
||||
$this->allowed_targets_parsed_cache = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this attachment type is allowed for the given attachment target.
|
||||
* @param Attachment|string $attachment
|
||||
* @return bool
|
||||
*/
|
||||
public function isAllowedForTarget(Attachment|string $attachment): bool
|
||||
{
|
||||
//If no restrictions are set, allow all targets
|
||||
if ($this->getAllowedTargets() === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Iterate over all allowed targets and check if the attachment is an instance of any of them
|
||||
foreach ($this->getAllowedTargets() as $allowed_target) {
|
||||
if (is_a($attachment, $allowed_target, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ enum PartStockChangeType: string
|
|||
case WITHDRAW = "withdraw";
|
||||
case MOVE = "move";
|
||||
|
||||
case STOCKTAKE = "stock_take";
|
||||
|
||||
/**
|
||||
* Converts the type to a short representation usable in the extra field of the log entry.
|
||||
* @return string
|
||||
|
|
@ -38,6 +40,7 @@ enum PartStockChangeType: string
|
|||
self::ADD => 'a',
|
||||
self::WITHDRAW => 'w',
|
||||
self::MOVE => 'm',
|
||||
self::STOCKTAKE => 's',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +55,7 @@ enum PartStockChangeType: string
|
|||
'a' => self::ADD,
|
||||
'w' => self::WITHDRAW,
|
||||
'm' => self::MOVE,
|
||||
's' => self::STOCKTAKE,
|
||||
default => throw new \InvalidArgumentException("Invalid short type: $value"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
|||
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
|
||||
{
|
||||
return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the instock change type of this entry
|
||||
* @return PartStockChangeType
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
|
||||
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
|
||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: [
|
||||
|
|
|
|||
|
|
@ -171,6 +171,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
|||
#[Length(max: 255)]
|
||||
protected ?string $user_barcode = null;
|
||||
|
||||
/**
|
||||
* @var \DateTimeImmutable|null The date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
|
||||
*/
|
||||
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
|
||||
#[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
#[Year2038BugWorkaround]
|
||||
protected ?\DateTimeImmutable $last_stocktake_at = null;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
|
|
@ -391,6 +399,26 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the date when the last stocktake was performed for this part lot. Returns null, if no stocktake was performed yet.
|
||||
* @return \DateTimeImmutable|null
|
||||
*/
|
||||
public function getLastStocktakeAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->last_stocktake_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
|
||||
* @param \DateTimeImmutable|null $last_stocktake_at
|
||||
* @return $this
|
||||
*/
|
||||
public function setLastStocktakeAt(?\DateTimeImmutable $last_stocktake_at): self
|
||||
{
|
||||
$this->last_stocktake_at = $last_stocktake_at;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[Assert\Callback]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits;
|
|||
|
||||
use App\Entity\Parts\InfoProviderReference;
|
||||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Validator\Constraints\ValidGTIN;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
|
@ -84,6 +85,14 @@ trait AdvancedPropertyTrait
|
|||
#[ORM\JoinColumn(name: 'id_part_custom_state')]
|
||||
protected ?PartCustomState $partCustomState = null;
|
||||
|
||||
/**
|
||||
* @var string|null The GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code
|
||||
*/
|
||||
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
#[ValidGTIN]
|
||||
protected ?string $gtin = null;
|
||||
|
||||
/**
|
||||
* Checks if this part is marked, for that it needs further review.
|
||||
*/
|
||||
|
|
@ -211,4 +220,26 @@ trait AdvancedPropertyTrait
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
|
||||
* Returns null if no GTIN is set.
|
||||
*/
|
||||
public function getGtin(): ?string
|
||||
{
|
||||
return $this->gtin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
|
||||
*
|
||||
* @param string|null $gtin The new GTIN of the part
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setGtin(?string $gtin): self
|
||||
{
|
||||
$this->gtin = $gtin;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection;
|
|||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Annotation\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
|
|
@ -147,6 +148,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
|||
#[ORM\JoinColumn(name: 'id_supplier')]
|
||||
protected ?Supplier $supplier = null;
|
||||
|
||||
/**
|
||||
* @var bool|null Whether the prices includes VAT or not. Null means, that it is not specified, if the prices includes VAT or not.
|
||||
*/
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true)]
|
||||
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
protected ?bool $prices_includes_vat = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pricedetails = new ArrayCollection();
|
||||
|
|
@ -388,6 +396,28 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the prices of this orderdetail include VAT. Null means, that it is not specified, if the prices includes
|
||||
* VAT or not.
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getPricesIncludesVAT(): ?bool
|
||||
{
|
||||
return $this->prices_includes_vat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the prices of this orderdetail include VAT.
|
||||
* @param bool|null $includesVat
|
||||
* @return $this
|
||||
*/
|
||||
public function setPricesIncludesVAT(?bool $includesVat): self
|
||||
{
|
||||
$this->prices_includes_vat = $includesVat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->getSupplierPartNr();
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
|||
#[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
|
||||
protected ?Orderdetail $orderdetail = null;
|
||||
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION);
|
||||
|
|
@ -264,6 +266,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
|||
return $this->currency?->getIsoCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getIncludesVat(): ?bool
|
||||
{
|
||||
return $this->orderdetail?->getPricesIncludesVAT();
|
||||
}
|
||||
|
||||
/********************************************************************************
|
||||
*
|
||||
* Setters
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable
|
|||
/**
|
||||
* The current schema version of the permission data
|
||||
*/
|
||||
public const CURRENT_SCHEMA_VERSION = 3;
|
||||
public const CURRENT_SCHEMA_VERSION = 4;
|
||||
|
||||
/**
|
||||
* Creates a new Permission Data Instance using the given data.
|
||||
|
|
|
|||
|
|
@ -22,17 +22,23 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form\AdminPages;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Attachments\ProjectAttachment;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Services\Attachments\FileTypeFilterTools;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
|
||||
class AttachmentTypeAdminForm extends BaseEntityAdminForm
|
||||
{
|
||||
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper)
|
||||
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
|
||||
{
|
||||
parent::__construct($security, $eventCommentNeededHelper);
|
||||
}
|
||||
|
|
@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm
|
|||
{
|
||||
$is_new = null === $entity->getID();
|
||||
|
||||
|
||||
$choiceLabel = function (string $class) {
|
||||
if (!is_a($class, Attachment::class, true)) {
|
||||
return $class;
|
||||
}
|
||||
return new StaticMessage($this->elementTypeNameGenerator->typeLabelPlural($class::ALLOWED_ELEMENT_CLASS));
|
||||
};
|
||||
|
||||
|
||||
$builder->add('allowed_targets', ChoiceType::class, [
|
||||
'required' => false,
|
||||
'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP),
|
||||
'choice_label' => $choiceLabel,
|
||||
'preferred_choices' => [PartAttachment::class, ProjectAttachment::class],
|
||||
'label' => 'attachment_type.edit.allowed_targets',
|
||||
'help' => 'attachment_type.edit.allowed_targets.help',
|
||||
'multiple' => true,
|
||||
]);
|
||||
|
||||
$builder->add('filetype_filter', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'attachment_type.edit.filetype_filter',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\AttachmentTypeType;
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
|
|
@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType
|
|||
'required' => false,
|
||||
'empty_data' => '',
|
||||
])
|
||||
->add('attachment_type', StructuralEntityType::class, [
|
||||
->add('attachment_type', AttachmentTypeType::class, [
|
||||
'label' => 'attachment.edit.attachment_type',
|
||||
'class' => AttachmentType::class,
|
||||
'disable_not_selectable' => true,
|
||||
'attachment_filter_class' => $options['data_class'] ?? null,
|
||||
'allow_add' => $this->security->isGranted('@attachment_types.create'),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ class PartFilterType extends AbstractType
|
|||
'min' => 0,
|
||||
]);
|
||||
|
||||
$builder->add('gtin', TextConstraintType::class, [
|
||||
'label' => 'part.gtin',
|
||||
]);
|
||||
|
||||
$builder->add('measurementUnit', StructuralEntityConstraintType::class, [
|
||||
'label' => 'part.edit.partUnit',
|
||||
'entity_class' => MeasurementUnit::class
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ class ScanDialogType extends AbstractType
|
|||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
||||
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
||||
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
|
||||
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
|
||||
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
|
||||
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form\Part;
|
||||
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Supplier;
|
||||
|
|
@ -73,6 +74,11 @@ class OrderdetailType extends AbstractType
|
|||
'label' => 'orderdetails.edit.obsolete',
|
||||
]);
|
||||
|
||||
$builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'orderdetails.edit.prices_includes_vat',
|
||||
]);
|
||||
|
||||
//Add pricedetails after we know the data, so we can set the default currency
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
|
||||
/** @var Orderdetail $orderdetail */
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
|
|
@ -63,6 +64,7 @@ class PartBaseType extends AbstractType
|
|||
protected UrlGeneratorInterface $urlGenerator,
|
||||
protected EventCommentNeededHelper $event_comment_needed_helper,
|
||||
protected IpnSuggestSettings $ipnSuggestSettings,
|
||||
private readonly LocalizationSettings $localizationSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +218,13 @@ class PartBaseType extends AbstractType
|
|||
'disable_not_selectable' => true,
|
||||
'label' => 'part.edit.partCustomState',
|
||||
])
|
||||
->add('ipn', TextType::class, $ipnOptions);
|
||||
->add('ipn', TextType::class, $ipnOptions)
|
||||
->add('gtin', TextType::class, [
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
'label' => 'part.gtin',
|
||||
])
|
||||
;
|
||||
|
||||
//Comment section
|
||||
$builder->add('comment', RichTextEditorType::class, [
|
||||
|
|
@ -261,6 +269,9 @@ class PartBaseType extends AbstractType
|
|||
'entity' => $part,
|
||||
]);
|
||||
|
||||
$orderdetailPrototype = new Orderdetail();
|
||||
$orderdetailPrototype->setPricesIncludesVAT($this->localizationSettings->pricesIncludeTaxByDefault);
|
||||
|
||||
//Orderdetails section
|
||||
$builder->add('orderdetails', CollectionType::class, [
|
||||
'entry_type' => OrderdetailType::class,
|
||||
|
|
@ -269,7 +280,7 @@ class PartBaseType extends AbstractType
|
|||
'allow_delete' => true,
|
||||
'label' => false,
|
||||
'by_reference' => false,
|
||||
'prototype_data' => new Orderdetail(),
|
||||
'prototype_data' => $orderdetailPrototype,
|
||||
'entry_options' => [
|
||||
'measurement_unit' => $part->getPartUnit(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType;
|
|||
use App\Form\Type\UserSelectType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
|
@ -110,6 +111,14 @@ class PartLotType extends AbstractType
|
|||
//Do not remove whitespace chars on the beginning and end of the string
|
||||
'trim' => false,
|
||||
]);
|
||||
|
||||
$builder->add('last_stocktake_at', DateTimeType::class, [
|
||||
'label' => 'part_lot.edit.last_stocktake_at',
|
||||
'widget' => 'single_text',
|
||||
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
|
|
|||
56
src/Form/Type/AttachmentTypeType.php
Normal file
56
src/Form/Type/AttachmentTypeType.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class.
|
||||
*/
|
||||
class AttachmentTypeType extends AbstractType
|
||||
{
|
||||
public function getParent(): ?string
|
||||
{
|
||||
return StructuralEntityType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);
|
||||
|
||||
$resolver->setDefault('class', AttachmentType::class);
|
||||
|
||||
$resolver->setDefault('choice_filter', function (Options $options) {
|
||||
if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) {
|
||||
return static function (?AttachmentType $choice) use ($options) {
|
||||
return $choice?->isAllowedForTarget($options['attachment_filter_class']);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -58,13 +58,13 @@ final class PartLotVoter extends Voter
|
|||
{
|
||||
}
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move', 'stocktake'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
|
||||
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
|
||||
if (in_array($attribute, ['withdraw', 'add', 'move', 'stocktake'], true))
|
||||
{
|
||||
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ class PartMerger implements EntityMergerInterface
|
|||
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'mass');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'gtin');
|
||||
|
||||
//Merge relations to other entities
|
||||
$this->useOtherValueIfNotNull($target, $other, 'manufacturer');
|
||||
|
|
@ -184,4 +185,4 @@ class PartMerger implements EntityMergerInterface
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class PartDetailDTO extends SearchResultDTO
|
|||
?ManufacturingStatus $manufacturing_status = null,
|
||||
?string $provider_url = null,
|
||||
?string $footprint = null,
|
||||
?string $gtin = null,
|
||||
public readonly ?string $notes = null,
|
||||
/** @var FileDTO[]|null */
|
||||
public readonly ?array $datasheets = null,
|
||||
|
|
@ -68,6 +69,7 @@ class PartDetailDTO extends SearchResultDTO
|
|||
manufacturing_status: $manufacturing_status,
|
||||
provider_url: $provider_url,
|
||||
footprint: $footprint,
|
||||
gtin: $gtin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ readonly class PriceDTO
|
|||
public string $price,
|
||||
/** @var string The currency of the used ISO code of this price detail */
|
||||
public ?string $currency_iso_code,
|
||||
/** @var bool If the price includes tax */
|
||||
/** @var bool If the price includes tax
|
||||
* @deprecated Use the prices_include_vat property of the PurchaseInfoDTO instead, as this property is not reliable if there are multiple prices with different values for includes_tax
|
||||
*/
|
||||
public ?bool $includes_tax = true,
|
||||
/** @var float the price related quantity */
|
||||
public ?float $price_related_quantity = 1.0,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ namespace App\Services\InfoProviderSystem\DTOs;
|
|||
*/
|
||||
readonly class PurchaseInfoDTO
|
||||
{
|
||||
/** @var bool|null If the prices contain VAT or not. Null if state is unknown. */
|
||||
public ?bool $prices_include_vat;
|
||||
|
||||
public function __construct(
|
||||
public string $distributor_name,
|
||||
public string $order_number,
|
||||
|
|
@ -36,6 +39,7 @@ readonly class PurchaseInfoDTO
|
|||
public array $prices,
|
||||
/** @var string|null An url to the product page of the vendor */
|
||||
public ?string $product_url = null,
|
||||
?bool $prices_include_vat = null,
|
||||
)
|
||||
{
|
||||
//Ensure that the prices are PriceDTO instances
|
||||
|
|
@ -44,5 +48,17 @@ readonly class PurchaseInfoDTO
|
|||
throw new \InvalidArgumentException('The prices array must only contain PriceDTO instances');
|
||||
}
|
||||
}
|
||||
|
||||
//If no prices_include_vat information is given, try to deduct it from the prices
|
||||
if ($prices_include_vat === null) {
|
||||
$vatValues = array_unique(array_map(fn(PriceDTO $price) => $price->includes_tax, $this->prices));
|
||||
if (count($vatValues) === 1) {
|
||||
$this->prices_include_vat = $vatValues[0]; //Use the value of the prices if they are all the same
|
||||
} else {
|
||||
$this->prices_include_vat = null; //If there are different values for the prices, we cannot determine if the prices include VAT or not
|
||||
}
|
||||
} else {
|
||||
$this->prices_include_vat = $prices_include_vat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ class SearchResultDTO
|
|||
public readonly ?string $provider_url = null,
|
||||
/** @var string|null A footprint representation of the providers page */
|
||||
public readonly ?string $footprint = null,
|
||||
/** @var string|null The GTIN / EAN of the part */
|
||||
public readonly ?string $gtin = null,
|
||||
)
|
||||
{
|
||||
if ($preview_image_url !== null) {
|
||||
|
|
@ -90,6 +92,7 @@ class SearchResultDTO
|
|||
'manufacturing_status' => $this->manufacturing_status?->value,
|
||||
'provider_url' => $this->provider_url,
|
||||
'footprint' => $this->footprint,
|
||||
'gtin' => $this->gtin,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +115,7 @@ class SearchResultDTO
|
|||
manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null,
|
||||
provider_url: $data['provider_url'] ?? null,
|
||||
footprint: $data['footprint'] ?? null,
|
||||
gtin: $data['gtin'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ final class DTOtoEntityConverter
|
|||
$entity->setPrice($dto->getPriceAsBigDecimal());
|
||||
$entity->setPriceRelatedQuantity($dto->price_related_quantity);
|
||||
|
||||
//Currency TODO
|
||||
if ($dto->currency_iso_code !== null) {
|
||||
$entity->setCurrency($this->getCurrency($dto->currency_iso_code));
|
||||
} else {
|
||||
|
|
@ -117,6 +116,8 @@ final class DTOtoEntityConverter
|
|||
$entity->addPricedetail($this->convertPrice($price));
|
||||
}
|
||||
|
||||
$entity->setPricesIncludesVAT($dto->prices_include_vat);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +176,8 @@ final class DTOtoEntityConverter
|
|||
$entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET);
|
||||
$entity->setManufacturerProductURL($dto->manufacturer_product_url ?? '');
|
||||
|
||||
$entity->setGtin($dto->gtin);
|
||||
|
||||
//Set the provider reference on the part
|
||||
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
preview_image_url: $result['image'] ?? null,
|
||||
provider_url: $this->getProductUrl($result['productId']),
|
||||
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
|
||||
gtin: $result['ean'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +303,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
|
||||
provider_url: $this->getProductUrl($data['shortProductNumber']),
|
||||
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
gtin: $data['productFullInformation']['eanCode'] ?? null,
|
||||
notes: $data['productFullInformation']['description'] ?? null,
|
||||
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
|
||||
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
|
|
@ -316,6 +318,8 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,10 +227,11 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
mpn: $product->mpn?->toString(),
|
||||
preview_image_url: $image,
|
||||
provider_url: $url,
|
||||
gtin: $product->gtin14?->toString() ?? $product->gtin13?->toString() ?? $product->gtin12?->toString() ?? $product->gtin8?->toString(),
|
||||
notes: $notes,
|
||||
parameters: $parameters,
|
||||
vendor_infos: $vendor_infos,
|
||||
mass: $mass
|
||||
mass: $mass,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +430,8 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ enum ProviderCapabilities
|
|||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/** Provider can provide GTIN for a part */
|
||||
case GTIN;
|
||||
|
||||
/**
|
||||
* Get the order index for displaying capabilities in a stable order.
|
||||
* @return int
|
||||
|
|
@ -55,6 +58,7 @@ enum ProviderCapabilities
|
|||
self::DATASHEET => 3,
|
||||
self::PRICE => 4,
|
||||
self::FOOTPRINT => 5,
|
||||
self::GTIN => 6,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +70,7 @@ enum ProviderCapabilities
|
|||
self::PICTURE => 'picture',
|
||||
self::DATASHEET => 'datasheet',
|
||||
self::PRICE => 'price',
|
||||
self::GTIN => 'gtin',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +82,7 @@ enum ProviderCapabilities
|
|||
self::PICTURE => 'fa-image',
|
||||
self::DATASHEET => 'fa-file-alt',
|
||||
self::PRICE => 'fa-money-bill-wave',
|
||||
self::GTIN => 'fa-barcode',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
$name = $element->filter('meta[itemprop="name"]')->attr('content');
|
||||
$sku = $element->filter('meta[itemprop="sku"]')->attr('content');
|
||||
|
||||
|
||||
|
||||
//Try to extract a picture URL:
|
||||
$pictureURL = $element->filter("div.al_artlogo img")->attr('src');
|
||||
|
||||
|
|
@ -95,7 +97,8 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
category: null,
|
||||
manufacturer: $sku,
|
||||
preview_image_url: $pictureURL,
|
||||
provider_url: $element->filter('a.al_artinfo_link')->attr('href')
|
||||
provider_url: $element->filter('a.al_artinfo_link')->attr('href'),
|
||||
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -146,6 +149,15 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
$priceString = $dom->filter('meta[itemprop="price"]')->attr('content');
|
||||
$currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR');
|
||||
|
||||
$gtin = null;
|
||||
foreach (['gtin13', 'gtin14', 'gtin12', 'gtin8'] as $gtinType) {
|
||||
if ($dom->filter("[itemprop=\"$gtinType\"]")->count() > 0) {
|
||||
$gtin = $dom->filter("[itemprop=\"$gtinType\"]")->innerText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Create purchase info
|
||||
$purchaseInfo = new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
|
|
@ -167,10 +179,11 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
mpn: $this->parseMPN($dom),
|
||||
preview_image_url: $json[0]['article_picture'],
|
||||
provider_url: $productPage,
|
||||
gtin: $gtin,
|
||||
notes: $notes,
|
||||
datasheets: $datasheets,
|
||||
parameters: $this->parseParameters($dom),
|
||||
vendor_infos: [$purchaseInfo]
|
||||
vendor_infos: [$purchaseInfo],
|
||||
);
|
||||
|
||||
}
|
||||
|
|
@ -273,6 +286,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::GTIN,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@ final class BarcodeRedirector
|
|||
return $this->getURLVendorBarcode($barcodeScan);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof GTINBarcodeScanResult) {
|
||||
return $this->getURLGTINBarcode($barcodeScan);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +115,16 @@ final class BarcodeRedirector
|
|||
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string
|
||||
{
|
||||
$part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
|
||||
if (!$part instanceof Part) {
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a part from a scan of a Vendor Barcode by filtering for parts
|
||||
* with the same Info Provider Id or, if that fails, by looking for parts with a
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ final class BarcodeScanHelper
|
|||
if ($type === BarcodeSourceType::EIGP114) {
|
||||
return $this->parseEIGP114Barcode($input);
|
||||
}
|
||||
if ($type === BarcodeSourceType::GTIN) {
|
||||
return $this->parseGTINBarcode($input);
|
||||
}
|
||||
|
||||
//Null means auto and we try the different formats
|
||||
$result = $this->parseInternalBarcode($input);
|
||||
|
|
@ -117,9 +120,19 @@ final class BarcodeScanHelper
|
|||
return $result;
|
||||
}
|
||||
|
||||
//If the result is a valid GTIN barcode, we can parse it directly
|
||||
if (GTINBarcodeScanResult::isValidGTIN($input)) {
|
||||
return $this->parseGTINBarcode($input);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown barcode');
|
||||
}
|
||||
|
||||
private function parseGTINBarcode(string $input): GTINBarcodeScanResult
|
||||
{
|
||||
return new GTINBarcodeScanResult($input);
|
||||
}
|
||||
|
||||
private function parseEIGP114Barcode(string $input): EIGP114BarcodeScanResult
|
||||
{
|
||||
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
||||
|
|
|
|||
|
|
@ -42,4 +42,9 @@ enum BarcodeSourceType
|
|||
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
|
||||
*/
|
||||
case EIGP114;
|
||||
}
|
||||
|
||||
/**
|
||||
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
|
||||
*/
|
||||
case GTIN;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
|
||||
use GtinValidation\GtinValidator;
|
||||
|
||||
readonly class GTINBarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
|
||||
private GtinValidator $validator;
|
||||
|
||||
public function __construct(
|
||||
public string $gtin,
|
||||
) {
|
||||
$this->validator = new GtinValidator($this->gtin);
|
||||
}
|
||||
|
||||
public function getDecodedForInfoMode(): array
|
||||
{
|
||||
$obj = $this->validator->getGtinObject();
|
||||
return [
|
||||
'GTIN' => $this->gtin,
|
||||
'GTIN type' => $obj->getType(),
|
||||
'Valid' => $this->validator->isValid() ? 'Yes' : 'No',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given input is a valid GTIN. This is used to determine whether a scanned barcode should be interpreted as a GTIN or not.
|
||||
* @param string $input
|
||||
* @return bool
|
||||
*/
|
||||
public static function isValidGTIN(string $input): bool
|
||||
{
|
||||
try {
|
||||
return (new GtinValidator($input))->isValid();
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -197,4 +197,45 @@ final class PartLotWithdrawAddHelper
|
|||
$this->entityManager->remove($origin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a stocktake for the given part lot, setting the amount to the given actual amount.
|
||||
* Please note that the changes are not flushed to DB yet, you have to do this yourself
|
||||
* @param PartLot $lot
|
||||
* @param float $actualAmount
|
||||
* @param string|null $comment
|
||||
* @param \DateTimeInterface|null $action_timestamp
|
||||
* @return void
|
||||
*/
|
||||
public function stocktake(PartLot $lot, float $actualAmount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): void
|
||||
{
|
||||
if ($actualAmount < 0) {
|
||||
throw new \InvalidArgumentException('Actual amount must be non-negative');
|
||||
}
|
||||
|
||||
$part = $lot->getPart();
|
||||
|
||||
//Check whether we have to round the amount
|
||||
if (!$part->useFloatAmount()) {
|
||||
$actualAmount = round($actualAmount);
|
||||
}
|
||||
|
||||
$oldAmount = $lot->getAmount();
|
||||
//Clear any unknown status when doing a stocktake, as we now have a known amount
|
||||
$lot->setInstockUnknown(false);
|
||||
$lot->setAmount($actualAmount);
|
||||
if ($action_timestamp) {
|
||||
$lot->setLastStocktakeAt(\DateTimeImmutable::createFromInterface($action_timestamp));
|
||||
} else {
|
||||
$lot->setLastStocktakeAt(new \DateTimeImmutable()); //Use now if no timestamp is given
|
||||
}
|
||||
|
||||
$event = PartStockChangedLogEntry::stocktake($lot, $oldAmount, $lot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
|
||||
$this->eventLogger->log($event);
|
||||
|
||||
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
|
||||
if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
|
||||
$this->eventCommentHelper->setMessage($comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,4 +157,20 @@ class PermissionSchemaUpdater
|
|||
$permissions->setPermissionValue('system', 'show_updates', $new_value);
|
||||
}
|
||||
}
|
||||
|
||||
private function upgradeSchemaToVersion4(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection
|
||||
{
|
||||
$permissions = $holder->getPermissions();
|
||||
|
||||
//If the reports.generate permission is not defined yet, set it to the value of reports.read
|
||||
if (!$permissions->isPermissionSet('parts_stock', 'stocktake')) {
|
||||
//Set the new permission to true only if both add and withdraw are allowed
|
||||
$new_value = TrinaryLogicHelper::and(
|
||||
$permissions->getPermissionValue('parts_stock', 'withdraw'),
|
||||
$permissions->getPermissionValue('parts_stock', 'add')
|
||||
);
|
||||
|
||||
$permissions->setPermissionValue('parts_stock', 'stocktake', $new_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ class UserAvatarHelper
|
|||
$attachment_type = new AttachmentType();
|
||||
$attachment_type->setName('Avatars');
|
||||
$attachment_type->setFiletypeFilter('image/*');
|
||||
$attachment_type->setAllowedTargets([UserAttachment::class]);
|
||||
$this->entityManager->persist($attachment_type);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ enum PartTableColumns : string implements TranslatableInterface
|
|||
case MPN = "manufacturer_product_number";
|
||||
case CUSTOM_PART_STATE = 'partCustomState';
|
||||
case MASS = "mass";
|
||||
case GTIN = "gtin";
|
||||
case TAGS = "tags";
|
||||
case ATTACHMENTS = "attachments";
|
||||
case EDIT = "edit";
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace App\Settings\SystemSettings;
|
|||
|
||||
use App\Form\Settings\LanguageMenuEntriesType;
|
||||
use App\Form\Type\LocaleSelectType;
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
|
||||
|
|
@ -46,7 +47,7 @@ class LocalizationSettings
|
|||
#[Assert\Locale()]
|
||||
#[Assert\NotBlank()]
|
||||
#[SettingsParameter(label: new TM("settings.system.localization.locale"), formType: LocaleSelectType::class,
|
||||
envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public string $locale = 'en';
|
||||
|
||||
#[Assert\Timezone()]
|
||||
|
|
@ -73,4 +74,14 @@ class LocalizationSettings
|
|||
)]
|
||||
#[Assert\All([new Assert\Locale()])]
|
||||
public array $languageMenuEntries = [];
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.system.localization.prices_include_tax_by_default"),
|
||||
description: new TM("settings.system.localization.prices_include_tax_by_default.description"),
|
||||
formType: TriStateCheckboxType::class
|
||||
)]
|
||||
/**
|
||||
* Indicates whether prices should include tax by default. This is used when creating new pricedetails.
|
||||
* Null means that the VAT state should be indetermine by default.
|
||||
*/
|
||||
public ?bool $pricesIncludeTaxByDefault = null;
|
||||
}
|
||||
|
|
|
|||
35
src/Validator/Constraints/ValidGTIN.php
Normal file
35
src/Validator/Constraints/ValidGTIN.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* A constraint to ensure that a GTIN is valid.
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
class ValidGTIN extends Constraint
|
||||
{
|
||||
|
||||
}
|
||||
54
src/Validator/Constraints/ValidGTINValidator.php
Normal file
54
src/Validator/Constraints/ValidGTINValidator.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use GtinValidation\GtinValidator;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class ValidGTINValidator extends ConstraintValidator
|
||||
{
|
||||
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!$constraint instanceof ValidGTIN) {
|
||||
throw new UnexpectedTypeException($constraint, ValidGTIN::class);
|
||||
}
|
||||
|
||||
if (null === $value || '' === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
throw new UnexpectedTypeException($value, 'string');
|
||||
}
|
||||
|
||||
$gtinValidator = new GtinValidator($value);
|
||||
if (!$gtinValidator->isValid()) {
|
||||
$this->context->buildViolation('validator.invalid_gtin')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
{% block additional_controls %}
|
||||
{{ form_row(form.filetype_filter) }}
|
||||
{{ form_row(form.allowed_targets) }}
|
||||
{{ form_row(form.alternative_names) }}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,17 @@
|
|||
{%- endif -%}
|
||||
{%- endblock tristate_widget %}
|
||||
|
||||
{% block tristate_row -%}
|
||||
<div{% with {attr: row_attr|merge({class: (row_attr.class|default('mb-3') ~ ' row')|trim})} %}{{ block('attributes') }}{% endwith %}>{#--#}
|
||||
<div class="{{ block('form_label_class') }}"></div>{#--#}
|
||||
<div class="{{ block('form_group_class') }}">
|
||||
{{- form_widget(form) -}}
|
||||
{{- form_help(form) -}}
|
||||
{{- form_errors(form) -}}
|
||||
</div>{#--#}
|
||||
</div>
|
||||
{%- endblock tristate_row %}
|
||||
|
||||
{%- block choice_widget_collapsed -%}
|
||||
{# Only add the BS5 form-select class if we dont use bootstrap-selectpicker #}
|
||||
{# {% if attr["data-controller"] is defined and attr["data-controller"] not in ["elements--selectpicker"] %}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@
|
|||
{% set preview_attach = part_preview_generator.tablePreviewAttachment(part) %}
|
||||
{% if preview_attach %}
|
||||
<img src="{{ attachment_thumbnail(preview_attach, 'thumbnail_xs') }}" class="entity-image-xs" alt="Part image"
|
||||
{{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}">
|
||||
{{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}">
|
||||
{% endif %}
|
||||
<a href="{{ entity_url(part) }}">{{ part.name }}</a>
|
||||
{% endmacro %}
|
||||
|
|
@ -241,3 +241,11 @@
|
|||
{{ datetime|format_datetime }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro vat_text(bool) %}
|
||||
{% if bool === true %}
|
||||
({% trans %}prices.incl_vat{% endtrans %})
|
||||
{% elseif bool === false %}
|
||||
({% trans %}prices.excl_vat{% endtrans %})
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,13 @@
|
|||
<small class="text-muted">{{ dto.footprint }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ helper.m_status_to_badge(dto.manufacturing_status) }}</td>
|
||||
<td>
|
||||
{{ helper.m_status_to_badge(dto.manufacturing_status) }}
|
||||
{% if dto.gtin %}
|
||||
<br>
|
||||
<span class="badge text-bg-secondary" title="{% trans %}part.gtin{% endtrans %}"><i class="fa-solid fa-barcode"></i> {{ dto.gtin }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if dto.provider_url %}
|
||||
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@
|
|||
{{ form_row(form.ipn) }}
|
||||
</div>
|
||||
{{ form_row(form.partUnit) }}
|
||||
{{ form_row(form.partCustomState) }}
|
||||
{{ form_row(form.partCustomState) }}
|
||||
{{ form_row(form.gtin) }}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
{{ form_row(form.supplierpartnr, {'attr': {'class': 'form-control-sm'}}) }}
|
||||
{{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }}
|
||||
{{ form_widget(form.obsolete) }}
|
||||
{{ form_widget(form.pricesIncludesVAT) }}
|
||||
</td>
|
||||
<td>
|
||||
<div {{ collection.controller(form.pricedetails, 'pricedetails.edit.delete.confirm') }}>
|
||||
|
|
@ -109,6 +110,7 @@
|
|||
{{ form_row(form.comment) }}
|
||||
{{ form_row(form.owner) }}
|
||||
{{ form_row(form.user_barcode) }}
|
||||
{{ form_row(form.last_stocktake_at) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -226,4 +228,4 @@
|
|||
{{ form_errors(form) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@
|
|||
<td>{{ part.ipn ?? 'part.ipn.not_defined'|trans }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>{% trans %}part.gtin{% endtrans %}</td>
|
||||
<td>{{ part.gtin ?? '' }}</td>
|
||||
</tr>
|
||||
|
||||
<tr> {# Favorite status #}
|
||||
<td>{% trans %}part.isFavorite{% endtrans %}</td>
|
||||
<td>{{ helper.boolean_badge(part.favorite) }}</td>
|
||||
|
|
@ -106,4 +111,4 @@
|
|||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
</td>
|
||||
<td>
|
||||
{% if order.pricedetails is not empty %}
|
||||
<table class="table table-bordered table-sm table-striped table-hover">
|
||||
<thead class="thead-dark">
|
||||
<table class="table table-bordered table-sm table-striped table-hover">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th>{% trans %}part.order.minamount{% endtrans %}</th>
|
||||
<th>{% trans %}part.order.price{% endtrans %}</th>
|
||||
|
|
@ -36,32 +36,35 @@
|
|||
{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for detail in order.pricedetails %}
|
||||
<tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for detail in order.pricedetails %}
|
||||
{# @var detail App\Entity\PriceInformations\Pricedetail #}
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
{{ detail.MinDiscountQuantity | format_amount(part.partUnit) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }}
|
||||
{% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %}
|
||||
{% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %}
|
||||
<span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ detail.PricePerUnit | format_money(detail.currency) }}
|
||||
{% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %}
|
||||
{% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %}
|
||||
<span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<td>
|
||||
{{ detail.MinDiscountQuantity | format_amount(part.partUnit) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }}
|
||||
{% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %}
|
||||
{% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %}
|
||||
<span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span>
|
||||
{% endif %}
|
||||
<small class="text-muted">{{- helper.vat_text(detail.includesVAT) -}}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ detail.PricePerUnit | format_money(detail.currency) }}
|
||||
{% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %}
|
||||
{% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %}
|
||||
<span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span>
|
||||
{% endif %}
|
||||
<small class="text-muted">{{- helper.vat_text(detail.includesVAT) -}}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td> {# Action for order information #}
|
||||
|
|
@ -80,4 +83,4 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
{% import "label_system/dropdown_macro.html.twig" as dropdown %}
|
||||
|
||||
{% include "parts/info/_withdraw_modal.html.twig" %}
|
||||
{% include "parts/info/_stocktake_modal.html.twig" %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
|
|
@ -19,53 +20,56 @@
|
|||
|
||||
<tbody>
|
||||
{% for lot in part.partLots %}
|
||||
{# @var lot App\Entity\Parts\PartLot #}
|
||||
<tr {% if lot.id == highlightLotId %}class="table-primary row-highlight row-pulse"{% endif %}>
|
||||
<td>{{ lot.description }}</td>
|
||||
<td>
|
||||
{% if lot.storageLocation %}
|
||||
{{ helper.structural_entity_link(lot.storageLocation) }}
|
||||
{% else %}
|
||||
<span class="badge rounded-pill bg-warning">
|
||||
<span class="badge rounded-pill text-bg-warning">
|
||||
<i class="fas fa-question-circle fa-fw"></i> {% trans %}part_lots.location_unknown{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if lot.instockUnknown %}
|
||||
<span class="badge rounded-pill bg-warning">
|
||||
<span class="badge rounded-pill text-bg-warning">
|
||||
<i class="fas fa-question-circle fa-fw"></i> {% trans %}part_lots.instock_unknown{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<h6>
|
||||
{% if lot.owner %}
|
||||
<span class="badge bg-body-tertiary mb-1" title="{% trans %}part_lot.owner{% endtrans %}">
|
||||
<td class="d-flex flex-column align-items-start">
|
||||
{% if lot.owner %}
|
||||
<span class="badge text-bg-light mb-1" title="{% trans %}part_lot.owner{% endtrans %}">
|
||||
{{ helper.user_icon_link(lot.owner) }}
|
||||
</span><br>
|
||||
{% endif %}
|
||||
{% if lot.expirationDate %}
|
||||
<span class="badge bg-info mb-1" title="{% trans %}part_lots.expiration_date{% endtrans %}">
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.expirationDate %}
|
||||
<span class="badge text-bg-info mb-1" title="{% trans %}part_lots.expiration_date{% endtrans %}">
|
||||
<i class="fas fa-calendar-alt fa-fw"></i> {{ lot.expirationDate | format_date() }}<br>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.expired %}
|
||||
<br>
|
||||
<span class="badge bg-warning mb-1">
|
||||
{% endif %}
|
||||
{% if lot.expired %}
|
||||
<span class="badge text-bg-warning mb-1">
|
||||
<i class="fas fa-exclamation-circle fa-fw"></i>
|
||||
{% trans %}part_lots.is_expired{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.needsRefill %}
|
||||
<br>
|
||||
<span class="badge bg-warning mb-1">
|
||||
<i class="fas fa-dolly fa-fw"></i>
|
||||
{% trans %}part_lots.need_refill{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
{% endif %}
|
||||
{% if lot.needsRefill %}
|
||||
<span class="badge text-bg-warning mb-1">
|
||||
<i class="fas fa-dolly fa-fw"></i>
|
||||
{% trans %}part_lots.need_refill{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.lastStocktakeAt %}
|
||||
<span class="badge text-bg-secondary" title="{% trans %}part_lot.edit.last_stocktake_at{% endtrans %}">
|
||||
<i class="fas fa-clipboard-check fa-fw"></i>
|
||||
{{ lot.lastStocktakeAt | format_datetime("short") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
|
|
@ -90,12 +94,15 @@
|
|||
>
|
||||
<i class="fa-solid fa-right-left fa-fw"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn-primary btn" data-bs-toggle="modal" data-bs-target="#stocktake-modal" title="{% trans %}part.info.stocktake_modal.title{% endtrans %}"
|
||||
{% if not is_granted('stocktake', lot) %}disabled{% endif %}
|
||||
data-lot-id="{{ lot.id }}" data-lot-amount="{{ lot.amount }}"><i class="fas fa-clipboard-check fa-fw"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}
|
||||
</td>
|
||||
<td>
|
||||
<td> {# Action for order information #}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
|
|
@ -111,7 +118,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if part.gtin %}
|
||||
<div>
|
||||
<h6>
|
||||
<span class="badge text-bg-secondary" title="{% trans %}part.gtin{% endtrans %}"><i class="fa-solid fa-barcode"></i> {{ part.gtin }}</span>
|
||||
</h6>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Needs Review tag #}
|
||||
{% if part.needsReview %}
|
||||
<div class="mt-1">
|
||||
|
|
|
|||
63
templates/parts/info/_stocktake_modal.html.twig
Normal file
63
templates/parts/info/_stocktake_modal.html.twig
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<div class="modal fade" id="stocktake-modal" tabindex="-1" aria-labelledby="stocktake-modal-title" aria-hidden="true" {{ stimulus_controller('pages/part_stocktake_modal') }}>
|
||||
<form method="post" action="{{ path('part_stocktake', {"id": part.id}) }}">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="stocktake-modal-title">{% trans %}part.info.stocktake_modal.title{% endtrans %}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{# non visible form elements #}
|
||||
<input type="hidden" name="lot_id" value="">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('part_stocktake-' ~ part.iD) }}">
|
||||
<input type="hidden" name="_redirect" value="{{ uri_without_host(app.request) }}">
|
||||
|
||||
<div class="row mb-2">
|
||||
<label class="col-form-label col-sm-3">
|
||||
{% trans %}part.info.stocktake_modal.expected_amount{% endtrans %}
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<span id="stocktake-modal-expected-amount" class="form-control-plaintext">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<label class="col-form-label col-sm-3">
|
||||
{% trans %}part.info.stocktake_modal.actual_amount{% endtrans %}
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="actual_amount" value="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mb-2">
|
||||
<label class="col-form-label col-sm-3">
|
||||
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" name="comment" value="">
|
||||
<div class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<label class="col-form-label col-sm-3">
|
||||
{% trans %}part.info.withdraw_modal.timestamp{% endtrans %}
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
{# The timestamp must be between a year ago and 1 hour in the future #}
|
||||
<input type="datetime-local" class="form-control" name="timestamp" value=""
|
||||
max="{{ "+10mins"|date('Y-m-d\\TH:i') }}" min="{{ "-1year"|date('Y-m-d\\TH:i') }}">
|
||||
<div class="form-text">{% trans %}part.info.withdraw_modal.timestamp.hint{% endtrans %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}modal.close{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-primary">{% trans %}modal.submit{% endtrans %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -65,6 +65,7 @@
|
|||
{{ form_row(filterForm.mass) }}
|
||||
{{ form_row(filterForm.dbId) }}
|
||||
{{ form_row(filterForm.ipn) }}
|
||||
{{ form_row(filterForm.gtin) }}
|
||||
{{ form_row(filterForm.lastModified) }}
|
||||
{{ form_row(filterForm.addedDate) }}
|
||||
</div>
|
||||
|
|
@ -163,4 +164,4 @@
|
|||
{{ form_end(filterForm) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ declare(strict_types=1);
|
|||
namespace App\Tests\Entity\Attachments;
|
||||
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Attachments\UserAttachment;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
|
|
@ -34,4 +36,51 @@ class AttachmentTypeTest extends TestCase
|
|||
$this->assertInstanceOf(Collection::class, $attachment_type->getAttachmentsForType());
|
||||
$this->assertEmpty($attachment_type->getFiletypeFilter());
|
||||
}
|
||||
|
||||
public function testSetAllowedTargets(): void
|
||||
{
|
||||
$attachmentType = new AttachmentType();
|
||||
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$attachmentType->setAllowedTargets(['target1', 'target2']);
|
||||
}
|
||||
|
||||
public function testGetSetAllowedTargets(): void
|
||||
{
|
||||
$attachmentType = new AttachmentType();
|
||||
|
||||
$attachmentType->setAllowedTargets([PartAttachment::class, UserAttachment::class]);
|
||||
$this->assertSame([PartAttachment::class, UserAttachment::class], $attachmentType->getAllowedTargets());
|
||||
//Caching should also work
|
||||
$this->assertSame([PartAttachment::class, UserAttachment::class], $attachmentType->getAllowedTargets());
|
||||
|
||||
//Setting null should reset the allowed targets
|
||||
$attachmentType->setAllowedTargets(null);
|
||||
$this->assertNull($attachmentType->getAllowedTargets());
|
||||
}
|
||||
|
||||
public function testIsAllowedForTarget(): void
|
||||
{
|
||||
$attachmentType = new AttachmentType();
|
||||
|
||||
//By default, all targets should be allowed
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class));
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class));
|
||||
|
||||
//Set specific allowed targets
|
||||
$attachmentType->setAllowedTargets([PartAttachment::class]);
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class));
|
||||
$this->assertFalse($attachmentType->isAllowedForTarget(UserAttachment::class));
|
||||
|
||||
//Set both targets
|
||||
$attachmentType->setAllowedTargets([PartAttachment::class, UserAttachment::class]);
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class));
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class));
|
||||
|
||||
//Reset allowed targets
|
||||
$attachmentType->setAllowedTargets(null);
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class));
|
||||
$this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,4 +61,18 @@ class OrderdetailTest extends TestCase
|
|||
$this->assertSame($price5, $orderdetail->findPriceForQty(5.3));
|
||||
$this->assertSame($price5, $orderdetail->findPriceForQty(10000));
|
||||
}
|
||||
|
||||
public function testGetSetPricesIncludesVAT(): void
|
||||
{
|
||||
$orderdetail = new Orderdetail();
|
||||
|
||||
//By default, the pricesIncludesVAT property should be null for empty orderdetails
|
||||
$this->assertNull($orderdetail->getPricesIncludesVAT());
|
||||
|
||||
$orderdetail->setPricesIncludesVAT(true);
|
||||
$this->assertTrue($orderdetail->getPricesIncludesVAT());
|
||||
|
||||
$orderdetail->setPricesIncludesVAT(false);
|
||||
$this->assertFalse($orderdetail->getPricesIncludesVAT());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ class BulkSearchResponseDTOTest extends KernelTestCase
|
|||
'manufacturing_status' => NULL,
|
||||
'provider_url' => NULL,
|
||||
'footprint' => NULL,
|
||||
'gtin' => NULL,
|
||||
),
|
||||
'source_field' => 'mpn',
|
||||
'source_keyword' => '1234',
|
||||
|
|
@ -129,6 +130,7 @@ class BulkSearchResponseDTOTest extends KernelTestCase
|
|||
'manufacturing_status' => NULL,
|
||||
'provider_url' => NULL,
|
||||
'footprint' => NULL,
|
||||
'gtin' => NULL,
|
||||
),
|
||||
'source_field' => 'name',
|
||||
'source_keyword' => '1234',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Tests\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
|
|
@ -33,4 +34,40 @@ class PurchaseInfoDTOTest extends TestCase
|
|||
$this->expectExceptionMessage('The prices array must only contain PriceDTO instances');
|
||||
new PurchaseInfoDTO('test', 'test', [new \stdClass()]);
|
||||
}
|
||||
|
||||
public function testPricesIncludesVATHandling(): void
|
||||
{
|
||||
$pricesTrue = [
|
||||
new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: true),
|
||||
new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: true),
|
||||
];
|
||||
$pricesFalse = [
|
||||
new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: false),
|
||||
new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: false),
|
||||
];
|
||||
$pricesMixed = [
|
||||
new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: true),
|
||||
new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: false),
|
||||
];
|
||||
$pricesNull = [
|
||||
new PriceDTO(minimum_discount_amount: 1, price: '10.00', currency_iso_code: 'USD', includes_tax: null),
|
||||
new PriceDTO(minimum_discount_amount: 5, price: '9.00', currency_iso_code: 'USD', includes_tax: null),
|
||||
];
|
||||
|
||||
//If the prices_include_vat parameter is given, use it:
|
||||
$dto = new PurchaseInfoDTO('test', 'test', $pricesMixed, prices_include_vat: true);
|
||||
$this->assertTrue($dto->prices_include_vat);
|
||||
$dto = new PurchaseInfoDTO('test', 'test', $pricesMixed, prices_include_vat: false);
|
||||
$this->assertFalse($dto->prices_include_vat);
|
||||
|
||||
//If the prices_include_vat parameter is not given, try to deduct it from the prices:
|
||||
$dto = new PurchaseInfoDTO('test', 'test', $pricesTrue);
|
||||
$this->assertTrue($dto->prices_include_vat);
|
||||
$dto = new PurchaseInfoDTO('test', 'test', $pricesFalse);
|
||||
$this->assertFalse($dto->prices_include_vat);
|
||||
$dto = new PurchaseInfoDTO('test', 'test', $pricesMixed);
|
||||
$this->assertNull($dto->prices_include_vat);
|
||||
$dto = new PurchaseInfoDTO('test', 'test', $pricesNull);
|
||||
$this->assertNull($dto->prices_include_vat);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ class DTOtoEntityConverterTest extends WebTestCase
|
|||
minimum_discount_amount: 5,
|
||||
price: "10.0",
|
||||
currency_iso_code: 'EUR',
|
||||
includes_tax: true,
|
||||
);
|
||||
|
||||
$entity = $this->service->convertPrice($dto);
|
||||
|
|
@ -115,6 +114,7 @@ class DTOtoEntityConverterTest extends WebTestCase
|
|||
order_number: 'TestOrderNumber',
|
||||
prices: $prices,
|
||||
product_url: 'https://example.com',
|
||||
prices_include_vat: true,
|
||||
);
|
||||
|
||||
$entity = $this->service->convertPurchaseInfo($dto);
|
||||
|
|
@ -122,6 +122,7 @@ class DTOtoEntityConverterTest extends WebTestCase
|
|||
$this->assertSame($dto->distributor_name, $entity->getSupplier()->getName());
|
||||
$this->assertSame($dto->order_number, $entity->getSupplierPartNr());
|
||||
$this->assertEquals($dto->product_url, $entity->getSupplierProductUrl());
|
||||
$this->assertTrue($dto->prices_include_vat);
|
||||
}
|
||||
|
||||
public function testConvertFileWithName(): void
|
||||
|
|
@ -159,12 +160,13 @@ class DTOtoEntityConverterTest extends WebTestCase
|
|||
$shopping_infos = [new PurchaseInfoDTO('TestDistributor', 'TestOrderNumber', [new PriceDTO(1, "10.0", 'EUR')])];
|
||||
|
||||
$dto = new PartDetailDTO(
|
||||
provider_key: 'test_provider', provider_id: 'test_id', provider_url: 'https://invalid.invalid/test_id',
|
||||
name: 'TestPart', description: 'TestDescription', category: 'TestCategory',
|
||||
manufacturer: 'TestManufacturer', mpn: 'TestMPN', manufacturing_status: ManufacturingStatus::EOL,
|
||||
preview_image_url: 'https://invalid.invalid/image.png',
|
||||
footprint: 'DIP8', notes: 'TestNotes', mass: 10.4,
|
||||
parameters: $parameters, datasheets: $datasheets, vendor_infos: $shopping_infos, images: $images
|
||||
provider_key: 'test_provider', provider_id: 'test_id', name: 'TestPart',
|
||||
description: 'TestDescription', category: 'TestCategory', manufacturer: 'TestManufacturer',
|
||||
mpn: 'TestMPN', preview_image_url: 'https://invalid.invalid/image.png',
|
||||
manufacturing_status: ManufacturingStatus::EOL,
|
||||
provider_url: 'https://invalid.invalid/test_id',
|
||||
footprint: 'DIP8', gtin: "1234567890123", notes: 'TestNotes', datasheets: $datasheets,
|
||||
images: $images, parameters: $parameters, vendor_infos: $shopping_infos, mass: 10.4
|
||||
);
|
||||
|
||||
$entity = $this->service->convertPart($dto);
|
||||
|
|
@ -180,6 +182,8 @@ class DTOtoEntityConverterTest extends WebTestCase
|
|||
$this->assertEquals($dto->mass, $entity->getMass());
|
||||
$this->assertEquals($dto->footprint, $entity->getFootprint());
|
||||
|
||||
$this->assertEquals($dto->gtin, $entity->getGtin());
|
||||
|
||||
//We just check that the lenghts of parameters, datasheets, images and shopping infos are the same
|
||||
//The actual content is tested in the corresponding tests
|
||||
$this->assertCount(count($parameters), $entity->getParameters());
|
||||
|
|
|
|||
|
|
@ -154,4 +154,19 @@ class PartLotWithdrawAddHelperTest extends WebTestCase
|
|||
$this->assertEqualsWithDelta(5.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
|
||||
$this->assertEqualsWithDelta(2.0, $this->partLot3->getAmount(), PHP_FLOAT_EPSILON);
|
||||
}
|
||||
|
||||
public function testStocktake(): void
|
||||
{
|
||||
//Stocktake lot 1 to 20
|
||||
$this->service->stocktake($this->partLot1, 20, "Test");
|
||||
$this->assertEqualsWithDelta(20.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON);
|
||||
$this->assertNotNull($this->partLot1->getLastStocktakeAt()); //Stocktake date should be set
|
||||
|
||||
//Stocktake lot 2 to 5
|
||||
$this->partLot2->setInstockUnknown(true);
|
||||
$this->service->stocktake($this->partLot2, 0, "Test");
|
||||
$this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
|
||||
$this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
tests/Validator/Constraints/ValidGTINValidatorTest.php
Normal file
72
tests/Validator/Constraints/ValidGTINValidatorTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Validator\Constraints\ValidGTINValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\ConstraintValidatorInterface;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
class ValidGTINValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
|
||||
public function testAllowNull(): void
|
||||
{
|
||||
$this->validator->validate(null, new \App\Validator\Constraints\ValidGTIN());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testValidGTIN8(): void
|
||||
{
|
||||
$this->validator->validate('12345670', new \App\Validator\Constraints\ValidGTIN());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testValidGTIN12(): void
|
||||
{
|
||||
$this->validator->validate('123456789012', new \App\Validator\Constraints\ValidGTIN());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testValidGTIN13(): void
|
||||
{
|
||||
$this->validator->validate('1234567890128', new \App\Validator\Constraints\ValidGTIN());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testValidGTIN14(): void
|
||||
{
|
||||
$this->validator->validate('12345678901231', new \App\Validator\Constraints\ValidGTIN());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testInvalidGTIN(): void
|
||||
{
|
||||
$this->validator->validate('1234567890123', new \App\Validator\Constraints\ValidGTIN());
|
||||
$this->buildViolation('validator.invalid_gtin')
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
protected function createValidator(): ConstraintValidatorInterface
|
||||
{
|
||||
return new ValidGTINValidator();
|
||||
}
|
||||
}
|
||||
|
|
@ -12401,5 +12401,113 @@ Buerklin-API Authentication server:
|
|||
<target>Update to</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XPhnMxn" name="part.gtin">
|
||||
<segment>
|
||||
<source>part.gtin</source>
|
||||
<target>GTIN / EAN</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TyykD7B" name="info_providers.capabilities.gtin">
|
||||
<segment>
|
||||
<source>info_providers.capabilities.gtin</source>
|
||||
<target>GTIN / EAN</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="JBGly8p" name="part.table.gtin">
|
||||
<segment>
|
||||
<source>part.table.gtin</source>
|
||||
<target>GTIN</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0qHQof." name="scan_dialog.mode.gtin">
|
||||
<segment>
|
||||
<source>scan_dialog.mode.gtin</source>
|
||||
<target>GTIN / EAN barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cmchX59" name="attachment_type.edit.allowed_targets">
|
||||
<segment>
|
||||
<source>attachment_type.edit.allowed_targets</source>
|
||||
<target>Use only for</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="t5R8p1l" name="attachment_type.edit.allowed_targets.help">
|
||||
<segment>
|
||||
<source>attachment_type.edit.allowed_targets.help</source>
|
||||
<target>Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="LvlEUjC" name="orderdetails.edit.prices_includes_vat">
|
||||
<segment>
|
||||
<source>orderdetails.edit.prices_includes_vat</source>
|
||||
<target>Prices include VAT</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GUsVh5T" name="prices.incl_vat">
|
||||
<segment>
|
||||
<source>prices.incl_vat</source>
|
||||
<target>Incl. VAT</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3ipwaVQ" name="prices.excl_vat">
|
||||
<segment>
|
||||
<source>prices.excl_vat</source>
|
||||
<target>Excl. VAT</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WDJ7EeF" name="settings.system.localization.prices_include_tax_by_default">
|
||||
<segment>
|
||||
<source>settings.system.localization.prices_include_tax_by_default</source>
|
||||
<target>Prices include VAT by default</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="01oGY_r" name="settings.system.localization.prices_include_tax_by_default.description">
|
||||
<segment>
|
||||
<source>settings.system.localization.prices_include_tax_by_default.description</source>
|
||||
<target>The default value for newly created purchase infos, if prices include VAT or not.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="heWSnAH" name="part_lot.edit.last_stocktake_at">
|
||||
<segment>
|
||||
<source>part_lot.edit.last_stocktake_at</source>
|
||||
<target>Last stocktake</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".LP93kG" name="perm.parts_stock.stocktake">
|
||||
<segment>
|
||||
<source>perm.parts_stock.stocktake</source>
|
||||
<target>Stocktake</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Vnhrb5R" name="part.info.stocktake_modal.title">
|
||||
<segment>
|
||||
<source>part.info.stocktake_modal.title</source>
|
||||
<target>Stocktake lot</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WqOG7RK" name="part.info.stocktake_modal.expected_amount">
|
||||
<segment>
|
||||
<source>part.info.stocktake_modal.expected_amount</source>
|
||||
<target>Expected amount</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="E7IbVN6" name="part.info.stocktake_modal.actual_amount">
|
||||
<segment>
|
||||
<source>part.info.stocktake_modal.actual_amount</source>
|
||||
<target>Actual amount</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4GwSma7" name="log.part_stock_changed.stock_take">
|
||||
<segment>
|
||||
<source>log.part_stock_changed.stock_take</source>
|
||||
<target>Stocktake</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aRQPMW7" name="log.element_edited.changed_fields.last_stocktake_at">
|
||||
<segment>
|
||||
<source>log.element_edited.changed_fields.last_stocktake_at</source>
|
||||
<target>Last stocktake</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
|
|
@ -247,5 +247,11 @@
|
|||
<target>There is already a translation defined for this type and language!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||
<segment>
|
||||
<source>validator.invalid_gtin</source>
|
||||
<target>This is not an valid GTIN / EAN!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue