first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
<?php
$txt_str['productsUnits'] = [
'short_name_frontend' => 'Zkratka pro web',
'short_name_frontend_tooltip' => 'Více jednotek, např. pro balení (4ks, 2ks), bude mít na frontendu vždy zkratku "Ks", ale aby se dalo rozlišit v administraci, zvolíme pro administraci různé zkratky (např. B1 - balení po 1, B2 - balení po 2, atd.). ',
'short_name_admin' => 'Zkratka pro admin',
'long_name' => 'Dlouhý název',
'pieces_precision' => 'Počet desetinných míst',
'pieces_in_package' => 'Ks v balení',
'flapUnit' => 'Jednotka',
'recalculate_to' => 'Přepočet ceny na',
'measure_quantity' => 'Jednotek v balení',
'toolbar_list' => 'Seznam jednotek',
'toolbar_add' => 'Přidat novou jednotku',
'titleAdd' => 'Přidat jednotku',
];

View File

@@ -0,0 +1,20 @@
<?php
use KupShop\AdminBundle\AdminList\BaseList;
class ProductsUnitsList extends BaseList
{
protected $tableName = 'products_units';
protected ?string $tableAlias = 'pu';
protected $tableDef = [
'id' => 'id',
'fields' => [
'ID' => ['field' => 'id'],
'Zkratka' => ['field' => 'short_name'],
'Zkratka v administraci' => ['field' => 'short_name_admin'],
'Dlouhý název' => ['field' => 'long_name'],
'Přepočet ceny na' => ['field' => 'recalculate_to'],
],
];
}

View File

@@ -0,0 +1,36 @@
<?php
class ProductsUnits extends Window
{
protected $template = 'window/products.units.tpl';
protected $tableName = 'products_units';
protected $nameField = 'short_name';
public function getData()
{
$data = parent::getData();
if (getVal('Submit')) {
if (!empty($data['pieces_in_package']) && $data['pieces_precision'] > 1) {
$data['pieces_precision'] = $data['pieces_in_package'];
}
if (empty($data['short_name_admin'])) {
$data['short_name_admin'] = $data['short_name'];
}
}
return $data;
}
public function handleDelete()
{
if ($this->getID() == 1) {
$this->returnError('Základní jednotku nelze smazat!');
}
parent::handleDelete();
}
}
return ProductsUnits::class;

View File

@@ -0,0 +1,84 @@
{extends file="[shared]/window.tpl"}
{block tabs}
{windowTab id='flapUnit'}
{/block}
{block tabsContent}
<div id="flapUnit" class="tab-pane fade active in boxFlex">
<div class="form-group">
<div class="col-md-2 control-label">
<label>{'short_name_frontend'|translate}</label>
<a class="help-tip" data-toggle="tooltip" title="{'short_name_frontend_tooltip'|translate}"><i
class="bi bi-question-circle"></i></a>
</div>
<div class="col-md-2">
<input type="text" class="form-control input-sm" name="data[short_name]" maxlength="100" value="{$body.data.short_name}"/>
</div>
<div class="col-md-2 control-label">
<label>{'short_name_admin'|translate}</label>
</div>
<div class="col-md-2">
<input type="text" class="form-control input-sm" name="data[short_name_admin]" maxlength="100"
value="{$body.data.short_name_admin}"/>
</div>
</div>
<div class="form-group">
<div class="col-md-2 control-label">
<label>{'long_name'|translate}</label>
</div>
<div class="col-md-10">
<input type="text" class="form-control input-sm" name="data[long_name]" maxlength="100" value="{$body.data.long_name}"/>
</div>
</div>
<div class="form-group">
<div class="col-md-2 control-label">
<label for="recalculate_to">{'recalculate_to'|translate}</label>
</div>
<div class="col-md-4 form-inline">
<input type="number" class="form-control input-sm" name="data[recalculate_to]" id="measure_unit" value="{$body.data.recalculate_to}"/>
{if $body.data.short_name}<span class="form-control-static">{$body.data.short_name}</span>{/if}
</div>
</div>
{if $module.PRODUCTS__UNITS_FLOAT}
<div class="form-group">
<div class="col-md-2 control-label two-lines">
<label>{'pieces_precision'|translate}</label>
</div>
<div class="col-md-4">
{print_select
name='data[pieces_precision]'
var=[
"1" => "0 (např. 1, 2, 3, 4 ...)",
"0.1" => "1 (např. 1.0, 1.1, 1.2, ...)",
"0.01" => "2 (např. 1.00, 1.01, 1.02, ...)",
"0.001" => "3 (např. 1.000, 1.001, 1.002, ...)",
"0.0001" => "4 (např. 1.0000, 1.0001, 1.0002, ...)",
"2" => "Balení"
]
selected=($body.data.pieces_precision > 1) ? "2" : (string)$body.data.pieces_precision
}
</div>
<div id='pieces_in_package' {if $body.data.pieces_precision <= 1}style="display:none"{/if}>
<div class="col-md-2 control-label">
<label>{'pieces_in_package'|translate}</label>
</div>
<div class="col-md-4">
<input type="text" class="form-control input-sm" name="data[pieces_in_package]" maxlength="100"
value="{if $body.data.pieces_precision > 1}{$body.data.pieces_precision}{/if}"/>
</div>
</div>
<script type="text/javascript">
$('select[name*="pieces_precision"]').change(function (e) {
var val = $(this).val();
$('#pieces_in_package').css('display', (val > 1) ? 'inline' : 'none');
}).change();
</script>
</div>
{/if}
{/block}

View File

@@ -0,0 +1,29 @@
<?php
namespace KupShop\UnitsBundle\AdminRegister;
use KupShop\AdminBundle\AdminRegister\AdminRegister;
use KupShop\AdminBundle\AdminRegister\IAdminRegisterDynamic;
use KupShop\AdminBundle\AdminRegister\IAdminRegisterStatic;
class UnitsAdminRegister extends AdminRegister implements IAdminRegisterDynamic, IAdminRegisterStatic
{
public function getDynamicMenu(): array
{
return [
static::createMenuItem('productsMenu',
[
'name' => 'productsUnits',
'left' => 's=menu.php&type=productsUnits', 'right' => 's=list.php&type=productsUnits',
'rights' => 'PRODUCT_',
]),
];
}
public static function getPermissions(): array
{
return [
static::createPermissions('productsUnits', [], ['PRODUCT'], false, [\Modules::PRODUCTS => \Modules::SUB_UNITS]),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace KupShop\UnitsBundle\EventListener;
use KupShop\KupShopBundle\Event\CreateMenuEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CreateMenuListener implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
CreateMenuEvent::COMPLETING_TREE => [
['addItem', 200],
],
];
}
/**
* @var CreateMenuEvent
*/
public function addItem(CreateMenuEvent $event)
{
$event->addItem('productsMenu',
[
'name' => 'productsUnits',
'left' => 's=menu.php&type=productsUnits', 'right' => 's=list.php&type=productsUnits',
'rights' => 'PRODUCT_',
]
);
$event->addRights('productsUnits',
[
'submodules' => [
\Modules::PRODUCTS => \Modules::SUB_UNITS,
],
'rights' => [],
]
);
}
}

View File

@@ -0,0 +1,7 @@
services:
_defaults:
autoconfigure: true
autowire: true
KupShop\UnitsBundle\:
resource: ../../{Utils,AdminRegister}

View File

@@ -0,0 +1,29 @@
<?php
use KupShop\ContentBundle\Entity\ProductUnified;
use KupShop\UnitsBundle\Utils\MeasureUnitUtil;
function smarty_function_get_measure_price($params, $smarty)
{
if (empty($params['product'])) {
throw new \KupShop\KupShopBundle\Exception\InvalidArgumentException('Parameter "product" is required');
}
$round = $params['round'] ?? false;
$productUnified = $params['product'];
if ($productUnified instanceof \KupShop\CatalogBundle\Entity\Wrapper\ProductWrapper || $productUnified instanceof Product) {
$product = \KupShop\CatalogBundle\Entity\Wrapper\ProductWrapper::unwrap($productUnified);
$product->fetchVariations();
$productUnified = new ProductUnified($product, $product->variations);
}
$result = \KupShop\KupShopBundle\Util\Compat\ServiceContainer::getService(MeasureUnitUtil::class)
->getUnitPrice($productUnified, $round);
if (!empty($params['assign'])) {
$smarty->assign($params['assign'], $result);
} else {
return $result;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace KupShop\UnitsBundle\Resources\upgrade;
class ProductsUnitsUpgrade extends \UpgradeNew
{
public function check_UnitsTable()
{
return $this->checkTableExists('products_units');
}
/** Create product units table */
public function upgrade_UnitsTable()
{
sqlQuery('CREATE TABLE products_units (
id INT AUTO_INCREMENT PRIMARY KEY ,
short_name VARCHAR(10),
long_name VARCHAR(255),
UNIQUE (short_name)
);
INSERT INTO products_units (short_name, long_name) VALUES (\'ks\', \'Počet kusů\');
');
$this->upgradeOK();
}
public function check_ProductsUnit()
{
return $this->checkColumnExists('products', 'unit');
}
/** Create product unit column */
public function upgrade_ProductsUnit()
{
sqlQuery('
ALTER TABLE products ADD COLUMN unit INT DEFAULT 1 NOT NULL AFTER in_store;
ALTER TABLE products ADD FOREIGN KEY (unit) REFERENCES products_units(\'id\');
');
$this->upgradeOK();
}
public function check_ProductsUnitPrecision()
{
return findModule(\Modules::PRODUCTS, \Modules::SUB_UNITS_FLOAT) && $this->checkColumnExists('products_units', 'pieces_precision');
}
/** Create product unit precision column */
public function upgrade_ProductsUnitPrecision()
{
sqlQuery('
ALTER TABLE products_units ADD COLUMN pieces_precision TINYINT(1) DEFAULT 0 NOT NULL;
');
$this->upgradeOK();
}
public function check_ProductsUnitMakeInStoreDecimal()
{
return findModule(\Modules::PRODUCTS, \Modules::SUB_UNITS_FLOAT) && $this->checkColumnType('products', 'in_store', 'DECIMAL(15,4)');
}
/** Update store to decimal values */
public function upgrade_ProductsUnitMakeInStoreDecimal()
{
sqlQuery('
ALTER TABLE products MODIFY COLUMN in_store DECIMAL(15,4) DEFAULT 0 NOT NULL;
ALTER TABLE products_variations MODIFY COLUMN in_store DECIMAL(15,4) DEFAULT 0 NOT NULL;
ALTER TABLE cart MODIFY COLUMN pieces DECIMAL(15,4) DEFAULT 0 NOT NULL;
ALTER TABLE order_items MODIFY COLUMN pieces DECIMAL(15,4) DEFAULT 1 NOT NULL;
ALTER TABLE stock_in_items MODIFY COLUMN quantity DECIMAL(15,4) not null;
');
$this->upgradeOK();
}
public function check_ProductsUnitsTable()
{
return findModule(\Modules::TRANSLATIONS) && $this->checkTableExists('products_units_translations');
}
/** Create products units translations table */
public function upgrade_ProductsUnitsTable()
{
sqlQuery('CREATE TABLE IF NOT EXISTS products_units_translations
(
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id_products_unit INT NOT NULL,
id_language VARCHAR(2) NOT NULL,
id_admin INT DEFAULT NULL,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated DATETIME DEFAULT NULL,
short_name VARCHAR(10) DEFAULT NULL,
long_name VARCHAR(255) DEFAULT NULL,
text TEXT DEFAULT NULL,
FOREIGN KEY (id_products_unit)
REFERENCES products_units(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE UNIQUE INDEX products_units_translations_id_unit_id_language_uindex
ON products_units_translations (id_products_unit, id_language);
');
$this->upgradeOK();
}
public function check_PrecisionType()
{
return findModule(\Modules::PRODUCTS, \Modules::SUB_UNITS_FLOAT) && $this->checkColumnType('products_units', 'pieces_precision', 'FLOAT');
}
/** Change pieces_precision type */
public function upgrade_PrecisionType()
{
sqlQuery('
ALTER TABLE products_units MODIFY COLUMN pieces_precision FLOAT DEFAULT 1 NOT NULL;
UPDATE products_units SET pieces_precision=0.01 WHERE pieces_precision=2;
UPDATE products_units SET pieces_precision=0.1 WHERE pieces_precision=1;
UPDATE products_units SET pieces_precision=1 WHERE pieces_precision=0;
');
$this->upgradeOK();
}
public function check_ShortNameAdmin()
{
return $this->checkColumnExists('products_units', 'short_name_admin');
}
/** Add admin short name */
public function upgrade_ShortNameAdmin()
{
sqlQuery('
ALTER TABLE products_units ADD COLUMN short_name_admin VARCHAR(10) AFTER short_name;
ALTER TABLE products_units DROP INDEX short_name;
ALTER TABLE products_units ADD UNIQUE (short_name_admin);
UPDATE products_units SET short_name_admin=short_name WHERE 1=1;
');
$this->upgradeOK();
}
public function check_ProductsFKExists(): bool
{
return $this->checkForeignKeyExists('products', 'unit');
}
/** Add unit column foreign key */
public function upgrade_ProductsFKExists(): void
{
sqlQuery('UPDATE products p LEFT JOIN products_units u ON u.id = p.unit SET p.unit = 1 WHERE u.id IS NULL');
sqlQuery('ALTER TABLE products ADD CONSTRAINT products_products_units_id_fk FOREIGN KEY (unit) REFERENCES products_units(id) ON UPDATE CASCADE;');
$this->upgradeOK();
}
public function check_measureUnit(): bool
{
return $this->checkColumnExists('products_units', 'recalculate_to');
}
/** ProductsUnits: add price unit */
public function upgrade_measureUnit(): void
{
sqlQuery('ALTER TABLE products_units ADD COLUMN recalculate_to INT NOT NULL DEFAULT 1');
$this->upgradeOK();
}
public function check_products()
{
return $this->checkColumnExists('products', 'measure_unit');
}
/** Add price unit to products */
public function upgrade_products()
{
// sqlQuery('ALTER TABLE products ADD COLUMN measure_unit INT NULL REFERENCES products_units(id) ON UPDATE CASCADE');
sqlQuery('ALTER TABLE products ADD COLUMN measure_unit INT NULL;');
sqlQuery('ALTER TABLE products ADD CONSTRAINT products_measure_unit_fk FOREIGN KEY (measure_unit)
REFERENCES products_units(id) ON UPDATE CASCADE;');
sqlQuery('ALTER table products ADD COLUMN measure_quantity DECIMAL(15,4) NULL DEFAULT NULL;');
sqlQuery('ALTER table products_variations ADD COLUMN measure_quantity DECIMAL(15,4) NULL DEFAULT NULL;');
$this->upgradeOK();
}
}

View File

@@ -0,0 +1,10 @@
{
"products_units": [
{
"id": 2,
"short_name": "M",
"long_name": "metry",
"pieces_precision": 0.01
}
]
}

View File

@@ -0,0 +1,49 @@
<?php
use KupShop\DevelopmentBundle\Util\Tests\CartTestTrait;
class CartUnits_FloatSuite extends DatabaseTestCase
{
use CartTestTrait;
public function getDataSet()
{
return $this->getJsonDataSetFromFile();
}
protected function tearDown(): void
{
parent::tearDown();
Delivery::clearCache();
}
public function testAddItemToCart()
{
$this->loginUser(1);
$this->updateSQL('products', ['unit' => 2], ['id' => 1]);
$this->createCart();
$this->insertProduct(1, 16, 1.35);
$this->createCart();
$this->assertEquals(1080, $this->cart->totalPriceWithVat->asFloat());
$this->checkOrderPriceIsSameAsCart();
}
public function testCorrectSubFromStore()
{
$this->loginUser(1);
$this->updateSQL('products', ['unit' => 2], ['id' => 1]);
$this->createCart();
$this->insertProduct(1, 16, '1,35');
$this->checkOrderPriceIsSameAsCart();
$in_store = sqlQueryBuilder()->select('in_store')->from('products_variations')->where('id = 16')->execute()->fetchColumn();
$this->assertEquals(-1.35, $in_store);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace KupShop\UnitsBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class UnitsBundle extends Bundle
{
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace KupShop\UnitsBundle\Utils;
use KupShop\CatalogBundle\ProductList\ProductCollection;
use KupShop\ContentBundle\Entity\ProductUnified;
use KupShop\ContentBundle\Entity\Wrapper\ProductUnifiedWrapper;
use KupShop\I18nBundle\Translations\ProductsUnitsTranslation;
use KupShop\I18nBundle\Util\PriceConverter;
use KupShop\KupShopBundle\Context\CurrencyContext;
use KupShop\KupShopBundle\Util\Price\Price;
use KupShop\KupShopBundle\Util\Price\ProductPrice;
use KupShop\KupShopBundle\Wrapper\PriceWrapper;
use Query\Operator;
use Query\Translation;
use Symfony\Contracts\Service\Attribute\Required;
class MeasureUnitUtil
{
protected ?PriceConverter $priceConverter;
protected CurrencyContext $currencyContext;
public function getUnitPrice(ProductUnified|ProductUnifiedWrapper $product, bool $round = false): ?array
{
$productCollection = new ProductCollection([$product->getId() => $product]);
$productCollection->fetchMeasureUnits();
if ($product instanceof ProductUnifiedWrapper) {
$product = $product->getObject();
}
if (empty($product->measureUnits)) {
return null;
}
// Select first variation by default or get data for product
$data = $product->measureUnits[array_key_first($product->measureUnits)] ?? null;
if ($product->getVariationId()) {
$data = $product->measureUnits[$product->getVariationId()] ?? null;
}
if (!$data) {
return null;
}
return $this->calculatePriceUnit($product->getProductPrice(), $data);
}
public function calculatePriceUnit(ProductPrice $productPrice, array $data): ?array
{
$measureQuantity = toDecimal($data['measure_quantity'] ?? null);
$measureUnit = toDecimal($data['recalculate_to'] ?? null);
if (!$measureQuantity->isPositive()) {
return null;
}
if (!$measureUnit->isPositive()) {
return null;
}
$wrapped = PriceWrapper::wrap($productPrice);
$price = $wrapped->field_value_without_vat_no_rounding();
$priceWithoutDiscount = $wrapped->field_value_without_vat_without_discount();
$pricePerUnit = $price->div($measureQuantity->div($measureUnit, 4), 4);
$priceWithoutDiscountPerUnit = $priceWithoutDiscount->div($measureQuantity->div($measureUnit, 4), 4);
$result = [
'price' => new Price($pricePerUnit, $this->currencyContext->getActive(), $productPrice->getVat()),
'recalculate_to' => $data['recalculate_to'],
'unit_name' => $data['short_name'],
];
if ($priceWithoutDiscountPerUnit->comp($pricePerUnit)) {
$result['price_without_discount'] = new Price($priceWithoutDiscountPerUnit, $this->currencyContext->getActive(), $productPrice->getVat());
}
return $result;
}
public function getUnitPriceQueryBuilder(?array $ids = null): \Query\QueryBuilder
{
$qb = sqlQueryBuilder()->select('p.id id_product, pv.id id_variation,
COALESCE(pv.measure_quantity, p.measure_quantity) measure_quantity, pu.recalculate_to, pu.id as id')
->fromProducts()
->joinVariationsOnProducts()
->innerJoin('p', 'products_units', 'pu', 'pu.id = p.measure_unit')
->andWhere(Translation::coalesceTranslatedFields(ProductsUnitsTranslation::class, ['short_name', 'long_name']));
if (findModule(\Modules::PRODUCTS, \Modules::SUB_UNITS_FLOAT)) {
$qb->addSelect('pu.pieces_precision as pieces_precision');
}
if ($ids) {
$qb->andWhere(Operator::inIntArray($ids, 'p.id'));
}
return $qb;
}
#[Required]
public function setPriceConverter(?PriceConverter $priceConverter): void
{
$this->priceConverter = $priceConverter;
}
#[Required]
public function setCurrencyContext(CurrencyContext $currencyContext): void
{
$this->currencyContext = $currencyContext;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace KupShop\UnitsBundle\Utils;
class PiecesRounder
{
protected $units;
public function roundPieces($product, $pieces)
{
$units = $this->loadUnits();
$unitID = $product->unit['id'] ?? null;
$pieces = str_replace(',', '.', $pieces);
if ($unitID && $units[$unitID]['pieces_precision']) {
switch ($units[$unitID]['pieces_precision']) {
case 0.0001: $round = 4;
break;
case 0.001: $round = 3;
break;
case 0.01: $round = 2;
break;
case 0.1: $round = 1;
break;
case 0:
default: $round = 0;
}
return round($pieces, $round);
}
return intval($pieces);
}
protected function loadUnits()
{
if (!$this->units) {
$this->units = sqlFetchAll(sqlQueryBuilder()
->select('*')
->from('products_units')
->execute(), 'id');
}
return $this->units;
}
}