first commit
This commit is contained in:
@@ -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;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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í',
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace KupShop\ProductReservationBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class ProductReservationBundle extends Bundle
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: false
|
||||
|
||||
KupShop\ProductReservationBundle\:
|
||||
resource: ../../{Admin/Tabs,Util}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user