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,45 @@
<?php
declare(strict_types=1);
namespace KupShop\ProductReservationBundle\Admin;
use KupShop\ProductReservationBundle\Util\ProductReservationUtil;
use Query\Operator;
class ProductReservation extends \Window
{
protected $tableName = 'product_reservations';
protected $nameField = 'id';
public function get_vars()
{
$vars = parent::get_vars();
$vars['body']['types'] = ProductReservationUtil::getReservationTypes();
return $vars;
}
public function processFormData()
{
$data = parent::processFormData();
if (empty($data['id_variation']) && $this->hasProductVariations((int) $data['id_product'])) {
$this->returnError(translate('errorVariationNotSelected'));
}
return $data;
}
private function hasProductVariations(int $productId): bool
{
return (bool) sqlQueryBuilder()
->select('id')
->from('products_variations')
->where(Operator::equals(['id_product' => $productId]))
->execute()->fetchOne();
}
}
return ProductReservation::class;

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace KupShop\ProductReservationBundle\Admin\Tabs;
use KupShop\AdminBundle\Admin\WindowTab;
class ProductReservationsTab extends WindowTab
{
protected $template = 'window/products.reservations.tpl';
protected $title = 'flapProductReservations';
public static function getTypes(): array
{
return [
'products' => 0,
];
}
public function getLabel()
{
return translate('flapProductReservations', 'ProductReservation');
}
}

View File

@@ -0,0 +1,13 @@
<?php
$txt_str['ProductReservation'] = [
'flapProductReservations' => 'Rezervace',
'addReservation' => 'Přidat rezervaci',
'errorVariationNotSelected' => 'Vyberte variantu!',
'type' => 'Typ',
'product' => 'Produkt',
'variation' => 'Varianta',
'quantity' => 'Množství',
];

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KupShop\ProductReservationBundle\Admin\lists;
use KupShop\AdminBundle\AdminList\BaseList;
use KupShop\ProductReservationBundle\Util\ProductReservationUtil;
use Query\Operator;
use Query\QueryBuilder;
class ProductReservationList extends BaseList
{
protected $tableDef = [
'id' => 'pr.id',
'fields' => [
'ID' => ['field' => 'id', 'visible' => 'Y'],
'type' => ['translate' => true, 'field' => 'type', 'visible' => 'Y', 'render' => 'renderType'],
'product' => ['translate' => true, 'field' => 'product_title', 'visible' => 'Y', 'spec' => []],
'variation' => ['translate' => true, 'field' => 'variation_title', 'visible' => 'Y', 'spec' => []],
'quantity' => ['translate' => true, 'field' => 'quantity', 'visible' => 'Y', 'render' => 'renderFloat'],
],
];
protected $tableName = 'product_reservations';
protected ?string $tableAlias = 'pr';
public function customizeTableDef($tableDef)
{
$tableDef = parent::customizeTableDef($tableDef);
$tableDef['fields']['product']['spec'] = function (QueryBuilder $qb) {
$qb->addSelect('p.title as product_title')
->join('pr', 'products', 'p', 'p.id = pr.id_product');
};
$tableDef['fields']['variation']['spec'] = function (QueryBuilder $qb) {
$qb->addSelect('pv.title as variation_title')
->leftJoin('pr', 'products_variations', 'pv', 'pv.id = pr.id_variation');
};
return $tableDef;
}
public function renderType(array $values): string
{
return ProductReservationUtil::getReservationTypes()[$values['type']];
}
public function getFilterQuery(): QueryBuilder
{
$qb = parent::getFilterQuery();
if ($productId = getVal('productId')) {
$qb->andWhere(Operator::equals(['pr.id_product' => $productId]));
}
return $qb;
}
}
return ProductReservationList::class;

View File

@@ -0,0 +1,55 @@
{extends "[shared]window.tpl"}
{block tabs}
{windowTab id='flapReservation'}
{/block}
{block tabsContent}
{$productId = $body.data.id_product}
{if !$productId}
{$productId = $smarty.get.productId}
{/if}
<div id="flapReservation" class="tab-pane fade active in boxStatic">
<div class="row wpj-form-group-flex">
<div class="col-xs-6">
<div class="wpj-form-group">
<label>{'product'|translate}</label>
<select class="selecter" name="data[id_product]" data-autocomplete="productId" data-preload="products">
<option value="{$productId}" selected>{$productId}</option>
</select>
</div>
</div>
<div class="col-xs-6">
<div class="wpj-form-group">
<label>{'variation'|translate:'ProductReservation'}</label>
<select class="selecter" name="data[id_variation]" data-autocomplete="variationsForProduct"
data-autocomplete-params="id_product={$productId}" data-preload="variations">
{if $body.data.id_variation}
<option value="{$body.data.id_variation}" selected>{$body.data.id_variation}</option>
{/if}
</select>
</div>
</div>
</div>
<div class="row wpj-form-group-flex">
<div class="col-xs-6">
<div class="wpj-form-group">
<label>{'type'|translate:'ProductReservation'}</label>
<select class="selecter" name="data[type]">
{foreach $body.types as $type => $name}
<option value="{$type}">{$name}</option>
{/foreach}
</select>
</div>
</div>
<div class="col-xs-6">
<div class="wpj-form-group">
<label>{'quantity'|translate:'ProductReservation'}</label>
<input class="form-control" name="data[quantity]" value="{$body.data.quantity}">
</div>
</div>
</div>
</div>
{/block}

View File

@@ -0,0 +1,18 @@
<div id="flapProductReservations" class="tab-pane fade in boxFlex box">
<div class="row m-b-1">
<div class="col-md-10">
<h1 class="h4 main-panel-title">{'flapProductReservations'|translate:'ProductReservation'}</h1>
</div>
<div class="col-md-2 text-right">
<a href="javascript:nw('ProductReservation', 0, 'productId={$body.data.id}')" class="btn btn-primary">
<i class="glyphicon glyphicon-plus"></i>
{'addReservation'|translate:'ProductReservation'}
</a>
</div>
</div>
<div class="row boxFlex box col-md-12">
<iframe class='on-demand boxFlex' src="" data-src="launch.php?s=list.php&type=ProductReservation&productId={$body.data.id}"></iframe>
</div>
</div>

View File

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

View File

@@ -0,0 +1,8 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: false
KupShop\ProductReservationBundle\:
resource: ../../{Admin/Tabs,Util}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace KupShop\ProductReservationBundle\Resources\upgrade;
use KupShop\ProductReservationBundle\Util\ProductReservationUtil;
class ProductReservationsUpgrade extends \UpgradeNew
{
public function check_ProductReservationsTable(): bool
{
return $this->checkTableExists('product_reservations');
}
/** Create `product_reservations` table */
public function upgrade_ProductReservationsTable(): void
{
sqlQuery('CREATE TABLE IF NOT EXISTS product_reservations (
id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
id_product INT(11) NOT NULL,
id_variation INT(11) DEFAULT NULL,
quantity INT(11) NOT NULL,
CONSTRAINT fk_product_reservations_id_product FOREIGN KEY (id_product) REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_product_reservations_id_variation FOREIGN KEY (id_variation) REFERENCES products_variations (id) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;');
$this->upgradeOK();
}
public function check_TypeColumn(): bool
{
return $this->checkColumnExists('product_reservations', 'type');
}
/** product_reservations: add `type` column */
public function upgrade_TypeColumn(): void
{
$types = array_map(fn ($x) => '"'.$x.'"', array_keys(ProductReservationUtil::getReservationTypes()));
if (empty($types)) {
$types[] = '""';
}
sqlQuery('ALTER TABLE product_reservations ADD COLUMN type ENUM('.implode(',', $types).') NOT NULL AFTER id');
sqlQuery('CREATE INDEX product_reservations_type_index ON product_reservations (type);');
$this->upgradeOK();
}
public function check_TypeEnum(): bool
{
return $this->checkEnumOptions('product_reservations', 'type', array_keys(ProductReservationUtil::getReservationTypes()));
}
/** Update product_reservations.type enum */
public function upgrade_TypeEnum(): void
{
$this->updateEnumOptions('product_reservations', 'type', array_keys(ProductReservationUtil::getReservationTypes()));
$this->upgradeOK();
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace KupShop\ProductReservationBundle\Tests;
use KupShop\CatalogBundle\ProductList\ProductList;
use KupShop\KupShopBundle\Context\UserContext;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\ProductReservationBundle\Util\ProductReservationUtil;
use Query\Operator;
class ProductReservationTest extends \DatabaseTestCase
{
private ProductReservationUtil $productReservationUtil;
private ProductList $productList;
protected function setUp(): void
{
parent::setUp();
$this->productReservationUtil = $this->get(ProductReservationUtil::class);
$this->productList = $this->get(ProductList::class);
}
public function testCreateProductReservation(): void
{
$this->assertNotEmpty(
$this->productReservationUtil->createProductReservation(3, null, 5, ProductReservationUtil::TYPE_RETAIL_RESERVE)
);
$this->assertEquals(5, $this->productReservationUtil->getProductReservedQuantity(3), 'Assert that reservation quantity is same as we set');
}
public function testCreateProductReservationWithInvalidType(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->productReservationUtil->createProductReservation(3, null, 5, 'nesmysl');
}
/** @dataProvider data_testProductsReservationMultiFetch */
public function testProductsReservationMultiFetch(int $productId, ?int $quantity): void
{
if ($quantity !== null) {
$this->productReservationUtil->createProductReservation($productId, null, $quantity, ProductReservationUtil::TYPE_RETAIL_RESERVE);
}
$products = $this->productList->andSpec(Operator::equals(['p.id' => $productId]))
->getProducts();
$products->fetchReservations();
$product = $products->current();
if ($quantity !== null) {
$this->assertNotEmpty($product->reservations);
$this->assertEquals($quantity, array_sum(array_map(fn ($x) => $x['quantity'], $product->reservations)));
return;
}
$this->assertEmpty($product->reservations);
}
public function data_testProductsReservationMultiFetch(): iterable
{
yield 'Multi fetch for product with existing reservation' => [3, 5];
yield 'Multi fetch for product with zero quantity reservation' => [3, 0];
yield 'Multi fetch for product without existing reservation' => [3, null];
}
/**
* @dataProvider data_testProductReservationIsAppliedOnProduct
*/
public function testProductReservationIsAppliedOnProduct(int $productId, ?int $variationId, int $quantity, string $reservationType, bool $withIsTypeB2B, int $expectedInStore): void
{
if ($withIsTypeB2B) {
Contexts::clear();
$this->mockUserContextWithIsTypeCallable(fn () => true);
}
$this->productReservationUtil->createProductReservation($productId, $variationId, $quantity, $reservationType);
$product = $variationId ? new \Variation($productId, $variationId) : new \Product($productId);
$product->createFromDB();
$this->assertEquals($expectedInStore, $product->inStore);
}
/**
* @dataProvider data_testProductReservationIsAppliedOnProduct
*/
public function testProductReservationIsAppliedOnProductList(int $productId, ?int $variationId, int $quantity, string $reservationType, bool $withIsTypeB2B, int $expectedInStore): void
{
if ($withIsTypeB2B) {
Contexts::clear();
$this->mockUserContextWithIsTypeCallable(fn () => true);
}
$this->productReservationUtil->createProductReservation($productId, $variationId, $quantity, $reservationType);
$productList = $this->productList;
// reset `resultModifiers` to remove call of `fetchDeliveryText` that changes inStore value to max(inStore, 0), so negative pieces are removed
$reflectionClass = new \ReflectionClass($productList);
$property = $reflectionClass->getProperty('resultModifiers');
$property->setAccessible(true);
$property->setValue($productList, []);
$product = $productList
->andSpec(Operator::equals(['p.id' => $productId]))
->fetchVariations(true, false)
->getProducts()
->current();
if ($variationId) {
$this->assertEquals($expectedInStore, $product->variations[$variationId]['in_store']);
return;
}
$this->assertEquals($expectedInStore, $product->inStore);
}
public function data_testProductReservationIsAppliedOnProduct(): iterable
{
yield 'Product store is 19 pcs, we are creating reservation for 10 pcs so expected store should be 9 pcs' => [9, null, 10, ProductReservationUtil::TYPE_RESERVATION, false, 9];
yield 'Product store is 0 pcs, we are creating reservation for 5 pcs so expected store should be -5 pcs' => [8, null, 5, ProductReservationUtil::TYPE_RESERVATION, false, -5];
yield 'Variation store is 2 pcs, we are creating reservation for 2 pcs so expected store should be 0 pcs' => [2, 9, 2, ProductReservationUtil::TYPE_RESERVATION, false, 0];
yield 'Variation store is 2 pcs, we are creating reservation for 2 pcs, but type TYPE_RETAIL_RESERVE is not active so expected store is still 2 pcs' => [2, 9, 2, ProductReservationUtil::TYPE_RETAIL_RESERVE, false, 2];
yield 'Variation store is 2 pcs, we are creating reservation for 2 pcs and type TYPE_RETAIL_RESERVE is active so expected store is 0 pcs' => [2, 9, 2, ProductReservationUtil::TYPE_RETAIL_RESERVE, true, 0];
}
private function mockUserContextWithIsTypeCallable(callable $fn): void
{
$mock = $this->autowire(UserContextMock::class);
$mock->_setIsTypeCallable($fn);
$this->set(UserContext::class, $mock);
}
}
class UserContextMock extends UserContext
{
private ?\Closure $_isTypeCallable = null;
public function _setIsTypeCallable(?callable $isTypeCallable): void
{
$this->_isTypeCallable = $isTypeCallable;
}
public function isType(string $type): bool
{
if ($this->_isTypeCallable) {
return ($this->_isTypeCallable)($type);
}
return parent::isType($type);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace KupShop\ProductReservationBundle\Util;
use Query\Operator;
class ProductReservationUtil
{
/** System reservation types */
public const TYPE_RESERVATION = 'reservation';
/** Manual reservation types */
public const TYPE_RETAIL_RESERVE = 'retail_reserve';
public static function getReservationTypes(): array
{
$types = [
self::TYPE_RETAIL_RESERVE => 'Maloobchodní rezerva',
];
if (findModule(\Modules::RESERVATIONS)) {
$types[self::TYPE_RESERVATION] = 'Rezervace';
}
return $types;
}
public function createProductReservation(int $productId, ?int $variationId, float $quantity, string $type): int
{
if (!isset(static::getReservationTypes()[$type])) {
throw new \InvalidArgumentException(
sprintf('Unknown product reservation type "%s"!', $type)
);
}
return sqlGetConnection()->transactional(function () use ($productId, $variationId, $quantity, $type) {
sqlQueryBuilder()
->insert('product_reservations')
->directValues(
[
'type' => $type,
'id_product' => $productId,
'id_variation' => $variationId,
'quantity' => $quantity,
]
)->execute();
return (int) sqlInsertId();
});
}
public function cancelProductReservation(int $productReservationId): bool
{
return sqlGetConnection()->transactional(function () use ($productReservationId) {
$item = sqlQueryBuilder()
->select('id_product, id_variation, quantity')
->from('product_reservations')
->where(Operator::equals(['id' => $productReservationId]))
->execute()->fetchAssociative();
if (!$item) {
throw new \RuntimeException('Trying to cancel non-existing product reservation!');
}
// prictu sklad zpatky k produktu
$product = new \Product($item['id_product']);
$product->storeIn($item['id_variation'], $item['quantity']);
// smazu zaznam se skladovou rezervaci
sqlQueryBuilder()
->delete('product_reservations')
->where(Operator::equals(['id' => $productReservationId]))
->execute();
return true;
});
}
public function getProductReservedQuantity(int $productId, ?int $variationId = null): float
{
return (float) sqlQueryBuilder()
->select('COALESCE(SUM(quantity), 0)')
->from('product_reservations')
->where(Operator::equalsNullable(['id_product' => $productId, 'id_variation' => $variationId]))
->execute()->fetchOne();
}
}