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,28 @@
<?php
$txt_str['productsBatches'] = [
'requireSerialNumber' => 'Požadovat sériové číslo',
'searchProduct' => 'Produkt',
'flapMain' => 'Šarže',
'searchNameCode' => 'Název / Kód produktu',
'filterByProduct' => 'Podle produktu',
'filterBasic' => 'Základní vyhledávání',
'searchVariation' => 'Varianty',
'searchBtn' => 'Hledat',
'delete' => 'Vymazat',
'search' => 'Vyhledávání',
'productBatch' => 'Kód šarže',
'dateExpiry' => 'Datum expirace',
'navigation' => 'Šarže',
'activityEdited' => 'Upravena šarže',
'titleEdit' => 'Upravit šarži',
'onlyInStock' => 'Pouze šarže skladem',
'dateFrom' => 'Datum od',
'dateTo' => 'Datum do',
'filterByDate' => 'Podle datumu',
'product' => 'Produkt',
'toolbar_list' => 'Seznam Šarží',
'toolbar_add' => 'Přidat šarži',
'batchesNearExpiration' => 'Šarže s blížící se expirací (%s)',
'batchesAfterExpiration' => 'Expirované šarže (%s)',
];

View File

@@ -0,0 +1,20 @@
<?php
$txt_str['productsBatches'] = [
'requireSerialNumber' => 'Require serial number',
'searchProduct' => 'Search product',
'flapMain' => 'Serial number',
'searchNameCode' => 'Name / code',
'filterByProduct' => 'By product',
'filterBasic' => 'Basic search',
'searchVariation' => 'Variations',
'searchBtn' => 'Search',
'delete' => 'Delete',
'search' => 'Search',
'serialNumber' => 'Serial number',
'searchOrder' => 'Order',
'navigation' => 'Serial numbers',
'titleEdit' => 'Edit serial number',
'batchesNearExpiration' => 'Batches near expiration (%s)',
'batchesAfterExpiration' => 'Expired batches (%s)',
];

View File

@@ -0,0 +1,158 @@
<?php
namespace KupShop\ProductsBatchesBundle\Admin\lists;
use KupShop\AdminBundle\AdminList\BaseList;
use KupShop\AdminBundle\AdminList\FiltersStorage;
use KupShop\AdminBundle\Query\Invert;
use KupShop\DevelopmentBundle\Query\QueryBuilder;
use KupShop\KupShopBundle\Util\HtmlBuilder\HTML;
use KupShop\StoresBundle\Utils\StoresInStore;
use Query\Operator;
class ProductsBatchesList extends BaseList
{
use FiltersStorage;
protected $tableDef = [
'id' => 'pb.id',
'fields' => [
'Šarže' => ['field' => 'code', 'size' => 0.3],
'Datum expirace' => ['field' => 'date_expiry', 'size' => 0.3, 'render' => 'renderDate'],
'Produkt' => ['field' => 'p_title', 'render' => 'renderName', 'size' => 2, 'type' => 'product', 'type_id' => 'p_id'],
'Kód produktu' => ['field' => 'pv_code', 'size' => 0.4],
'EAN produktu' => ['field' => 'pv_ean', 'size' => 0.4],
'Ve skladu' => ['field' => 'wp_pieces_total', 'size' => 0.4],
],
];
public function customizeTableDef($tableDef)
{
$tableDef = parent::customizeTableDef($tableDef);
if (findModule(\Modules::STORES)) {
$tableDef['fields']['Externí sklad'] = ['field' => 'si_pieces_total', 'render' => 'renderStores', 'size' => 0.5, 'class' => 'left tiny-list-items'];
}
return $tableDef;
}
public function renderName($values, $column)
{
$name = [$values['p_title'], $values['pv_title']];
return join(' - ', array_filter($name));
}
public function renderStores($values): HTML
{
$qb = sqlQueryBuilder()
->select("s.name, COALESCE(SUM(COALESCE(JSON_EXTRACT(custom_data, CONCAT('$.batch_numbers.', {$values['id']})), 0)),0) quantity")
->from('stores_items', 'si')
->join('si', 'stores', 's', 's.id = si.id_store')
->where(
Operator::andX(
Operator::equals(['si.id_product' => $values['id_product']])),
Operator::equalsNullable(['si.id_variation' => $values['id_variation']])
)
->andWhere(Operator::equals(['s.type' => StoresInStore::TYPE_EXTERNAL_STORE]))
->having('quantity > 0')
->groupBy('si.id_store')
->orderBy('si.id_store');
$output = HTML::create('span');
foreach ($qb->execute() as $item) {
$output
->tag('div')
->text($item['name'].': '.$item['quantity'])
->end();
}
return $output;
}
public function getQuery()
{
/** @var QueryBuilder $qb */
$qb = sqlQueryBuilder()
->select(
'pb.*',
'p.title p_title',
'p.id p_id',
'pv.title pv_title',
'pv.id pv_id',
'COALESCE(pv.code, p.code) pv_code',
'COALESCE(pv.ean, p.ean) pv_ean',
)->from('products_batches', 'pb')
->leftJoin('pb', 'products', 'p', 'p.id = pb.id_product')
->leftJoin('pb', 'products_variations', 'pv', 'pv.id = pb.id_variation')
->groupBy('pb.id, p.id, pv.id');
if (findModule(\Modules::STORES)) {
$externalSubquery = sqlQueryBuilder()->select("COALESCE(SUM(COALESCE(JSON_EXTRACT(custom_data, CONCAT('$.batch_numbers.',pb.id)), 0)),0) si_pieces_total")
->from('stores_items', 'si')
->where('pb.id_product = si.id_product && pb.id_variation <=> si.id_variation');
$qb->addSubselect($externalSubquery, 'si_pieces_total');
}
if (findModule(\Modules::WAREHOUSE)) {
$qb->addSelect('SUM(COALESCE(wp.pieces, 0)) wp_pieces_total')
->leftJoin('pb', 'warehouse_products', 'wp', 'pb.id = wp.id_product_batch');
}
if ($productBatch = getVal('productBatch')) {
$qb->andWhere(
Invert::checkInvert(\Query\Operator::equals(['pb.code' => $productBatch]), getVal('productBatch_invert'))
);
}
if ($dateFrom = getVal('dateFrom')) {
$qb->andWhere('pb.date_expiry >= :pb_date_expiry_from')->setParameter('pb_date_expiry_from',
$this->prepareDate($dateFrom).' 00:00:00');
}
if ($dateTo = getVal('dateTo')) {
$qb->andWhere('pb.date_expiry <= :pb_date_expiry_to')->setParameter('pb_date_expiry_to',
$this->prepareDate($dateTo).' 23:59:59');
}
if (getVal('onlyInStock')) {
if (findModule(\Modules::STORES)) {
$qb->andHaving('wp_pieces_total > 0 || si_pieces_total > 0');
} else {
$qb->andHaving('wp_pieces_total > 0');
}
}
if ($productId = getVal('idProduct')) {
$qb->andWhere(Operator::equals(['pb.id_product' => $productId]));
if ($variationId = getVal('idVariation')) {
if (intval($variationId) > 0) {
$qb->andWhere(Operator::equals(['pb.id_variation ' => $variationId]));
}
}
}
return $qb;
}
public function renderPrint($values, $column)
{
$href = 'launch.php?s=printCenter.php&type=product&template_type=serialNumber&ID='.$values['p_id'].
'&IDv='.$values['pv_id'].
'&sn='.$values['psn_serial_number'];
return HTML::create('a')
->attr('title', 'Vytisknout')
->attr('href', $href)
->tag('span')
->class('badge')
->tag('i')
->class('glyphicon glyphicon-print')
->end()
->end();
}
}
$main_class = ProductsBatchesList::class;

View File

@@ -0,0 +1,48 @@
<?php
namespace KupShop\ProductsBatchesBundle\Admin;
use Query\Operator;
class productsBatches extends \Window
{
protected $template = 'window/productsBatches.tpl';
protected $tableName = 'products_batches';
protected $nameField = 'code';
public function get_vars()
{
$vars = parent::get_vars();
$pageVars = getVal('body', $vars);
$data = &$pageVars['data'];
if (!empty($data['id_product'])) {
$item = sqlQueryBuilder()->select('p.title product_title', 'pv.title variation_title')
->from('products', 'p')
->leftJoin('p', 'products_variations', 'pv', 'p.id = pv.id_product')
->where(Operator::equalsNullable(['p.id' => $data['id_product'], 'pv.id' => $data['id_variation']]))
->execute()->fetch();
$data['product_title'] = $item['product_title'];
$data['variation_title'] = $item['variation_title'];
}
$vars['body'] = $pageVars;
return $vars;
}
public function getData()
{
$data = parent::getData();
if (getVal('Submit')) {
$data['date_expiry'] = $this->prepareDate($data['date_expiry']);
}
return $data;
}
}
return productsBatches::class;

View File

@@ -0,0 +1,94 @@
{extends "[shared]/menu.tpl"}
{block name="menu-items" append}
<li class="nav-header smaller"><i class="glyphicon glyphicon-search"></i><span>{'search'|translate}</span></li>
<li class="with_caret "><a href="#" class="opener"><span>{'filterBasic'|translate}</span></a></li>
<li class="pill-content">
<ul class="nav-sub nav-pills">
<form id='search' target="mainFrame" method="get" action="launch.php" class="form-inline">
<input type="hidden" name="type" value="productsBatches"/><input type="hidden" name="s" value="list.php">
<div class="form-group">
<div class="input-group invert">
<input type="text" class="form-control input-sm" name="productBatch" maxlength="20" value=""
placeholder="{'productBatch'|translate}"/>
{inversion field="productBatch"}
</div>
</div>
<div class="form-group">
<input type="reset" value="{'delete'|translate}" class="btn btn-danger btn-sm"/>
<input type="submit" value="{'searchBtn'|translate}" class="btn btn-primary btn-sm"/>
</div>
</form>
</ul>
</li>
<li class="with_caret"><a href="#" class="opener"><span>{'filterByProduct'|translate}</span></a></li>
<li class="pill-content">
<ul class="nav-sub nav-pills">
<form id='search' target="mainFrame" method="get" action="launch.php" class="form-inline">
<input type="hidden" name="type" value="{$type}"/><input type="hidden" name="s" value="list.php">
<div class="form-group">
<input type="text" class="form-control input-sm" name="idProduct" maxlength="100" value="" onKeyPress="checkInputData('int')"
placeholder="{'searchNameCode'|translate}"/>
</div>
<div class="form-group">
<select name="idVariation" class="input-sm form-control"></select>
<script type="text/javascript">
$(function () {
initAutocompleteVariation('[name=idProduct]', '[name=idVariation]');
});
</script>
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" class="input" id="onlyInStock0" name="onlyInStock" value="1"><label for="onlyInStock0">{'onlyInStock'|translate}</label>
</div>
</div>
<div class="form-group">
<input type="reset" id="resetBtn" value="{'delete'|translate}" class="btn btn-danger btn-sm"/>
<input type="submit" value="{'searchBtn'|translate}" class="btn btn-primary btn-sm"/>
</div>
</form>
</ul>
</li>
<li class="with_caret"><a href="#" class="opener"><span>{'filterByDate'|translate}</span></a></li>
<li class="pill-content">
<ul class="nav-sub nav-pills">
<form id='search' target="mainFrame" method="get" action="launch.php" class="form-inline">
<input type="hidden" name="type" value="{$type}"/><input type="hidden" name="s" value="list.php">
<div class="form-group">
<input type="text" class="form-control input-sm" name="dateFrom" id="dateFrom" maxlength="10" value=""
placeholder="{'dateFrom'|translate}" autocomplete="off"/>
{insert_calendar selector='#dateFrom' format='date'}
</div>
<div class="form-group">
<input type="text" class="form-control input-sm" name="dateTo" id="dateTo" maxlength="10" value=""
placeholder="{'dateTo'|translate}" autocomplete="off"/>
{insert_calendar selector='#dateTo' format='date'}
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" class="input" id="onlyInStock1" name="onlyInStock" value="1"><label for="onlyInStock1">{'onlyInStock'|translate}</label>
</div>
</div>
<div class="form-group">
<input type="reset" id="resetBtn" value="{'delete'|translate}" class="btn btn-danger btn-sm"/>
<input type="submit" value="{'searchBtn'|translate}" class="btn btn-primary btn-sm"/>
</div>
</form>
</ul>
</li>
{/block}

View File

@@ -0,0 +1,61 @@
{extends "[shared]/window.tpl"}
{block tabs}
{windowTab id='flapMain'}
{/block}
{block tabsContent}
<div id="flapMain" class="tab-pane fade active in boxFlex">
<div class="form-group">
<label for="code" class="col-md-2 control-label">
{'productBatch'|translate}
</label>
<div class="col-md-5">
<input type="text" class="form-control" id="code" maxlength="100" name="data[code]" value="{$body.data.code}"/>
</div>
</div>
<div class="form-group">
<label for="code" class="col-md-2 control-label">
{'dateExpiry'|translate}
</label>
<div class="col-md-5">
<input type="text" class="form-control " name="data[date_expiry]" id="date_issued" value="{$body.data.date_expiry|format_date:'admin'}" autocomplete="off" />
{insert_calendar selector='#date_issued' format='date'}
</div>
</div>
<div class="form-group">
<label for="code" class="col-md-2 control-label">
{'product'|translate}
</label>
{if empty($body.data.id_product)}
<div class="col-md-5">
<input type="text" class="form-control input-sm" id="id_product" name="data[id_product]" maxlength="100"
value="{$body.data.id_product}" onKeyPress="checkInputData('int')"
placeholder="{'searchNameCode'|translate:'orders'}"/>
</div>
<div class="col-md-4">
<select id="id_variation" name="data[id_variation]" class="input-sm form-control selecter"></select>
<script type="text/javascript">
$(function () {
initAutocompleteVariation('#id_product', '#id_variation');
});
</script>
</div>
{else}
<div class="col-md-4">
<p class="input-height"><a href="javascript:nw('product', {$body.data.id_product})">{$body.data.product_title} </a></p>
</div>
<div class="col-md-2 control-label"><label>Varianta</label></div>
<div class="col-md-3">
<p class=" input-height">{if $body.data.id_variation}{$body.data.variation_title}{else}Nemá{/if}</p>
</div>
<input type="hidden" name="data[id_product]" value="{$body.data.id_product}"/>
<input type="hidden" name="data[id_variation]" value="{$body.data.id_variation}"/>
{/if}
</div>
</div>
{/block}

View File

@@ -0,0 +1,32 @@
<?php
namespace KupShop\ProductsBatchesBundle\AdminRegister;
use KupShop\AdminBundle\AdminRegister\AdminRegister;
use KupShop\AdminBundle\AdminRegister\IAdminRegisterDynamic;
use KupShop\AdminBundle\AdminRegister\IAdminRegisterStatic;
class ProductsBatchesAdminRegister extends AdminRegister implements IAdminRegisterDynamic, IAdminRegisterStatic
{
public function getDynamicMenu(): array
{
return [
static::createMenuItem(
'stockMenu',
[
'name' => 'ProductsBatches',
'title' => translate('navigation', 'productsBatches'),
'left' => 's=menu.php&type=productsBatches',
'right' => 's=list.php&type=productsBatches',
]
),
];
}
public static function getPermissions(): array
{
return [
static::createPermissions('ProductsBatches', [\Modules::PRODUCTS_BATCHES], ['PBATCHES']),
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace KupShop\ProductsBatchesBundle\EventListener;
use KupShop\OrderingBundle\Event\OrderEvent;
use KupShop\WarehouseBundle\Entity\StoreItem;
use KupShop\WarehouseBundle\Event\WarehouseOrderEvent;
use KupShop\WarehouseBundle\Util\StoreItemWorker;
use Query\Operator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class OrderEventListener implements EventSubscriberInterface
{
/**
* @var StoreItemWorker
*/
protected $worker;
/**
* @required
*/
public function setWorker(?StoreItemWorker $worker): void
{
$this->worker = $worker;
}
public static function getSubscribedEvents()
{
return [
OrderEvent::ORDER_STORNO => [
['stornoProductsBatchesNumbers', -1],
],
];
}
public function stornoProductsBatchesNumbers(OrderEvent $event)
{
$order = $event->getOrder();
$warehouse_order = sqlQueryBuilder()
->select('*')
->from('warehouse_orders')
->where(Operator::equals(['id_order' => $order->id]))
->andWhere('id_position IS NULL')
->andWhere('date_finish IS NOT NULL')
->execute()
->fetch();
// Check if order is finished and does not have any box assigned
if ($warehouse_order) {
$logProducts = sqlQueryBuilder()->select('*')
->from('warehouse_log', 'wl')
->where(Operator::equals(['wl.id_order' => $order->id]))
->andWhere('wl.new_id_position IS NULL')
->andWhere('wl.id_product_batch IS NOT NULL')->execute()->fetchAll();
$event = new WarehouseOrderEvent();
$position = $this->worker->getPositionIdByCode($event->getReturnPosition());
// Select only items that have batch number, subtract them from generated items without batch, and create them again with batch id
foreach ($logProducts as $item) {
$itemNoBatch = $item;
$itemNoBatch['id_product_batch'] = null;
$this->worker->updateOnPosition(new StoreItem($itemNoBatch), $position, -$itemNoBatch['pieces']);
$this->worker->createStoreItem(new StoreItem($item), $item['pieces'], $position);
}
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace KupShop\ProductsBatchesBundle\Inspections;
use KupShop\SystemInspectionBundle\Inspections\Inspection;
use KupShop\SystemInspectionBundle\Inspections\User\UserInspectionInterface;
use KupShop\SystemInspectionBundle\InspectionWriters\MessageTypes\HtmlDataMessage;
use KupShop\SystemInspectionBundle\InspectionWriters\MessageTypes\InspectionMessage;
class BatchesExpirationInspection extends Inspection implements UserInspectionInterface
{
/**
* @return InspectionMessage[]|null
*/
public function runInspection(): ?array
{
$errors = [];
$oldBatches = sqlQueryBuilder()->select('pb.id, pb.code kod, pb.date_expiry expirace, SUM(wp.pieces) fyzicky_skladem')
->from('products_batches', 'pb')
->innerJoin('pb', 'warehouse_products', 'wp', 'wp.id_product_batch = pb.id')
->where('pb.date_expiry < (NOW() + INTERVAL 2 MONTH)')
->groupBy('pb.id')
->having('fyzicky_skladem > 0');
$nearExpiration = (clone $oldBatches)->andWhere('pb.date_expiry > NOW()')->execute()->fetchAllAssociative();
$afterExpiration = $oldBatches->andWhere('pb.date_expiry <= NOW()')->execute()->fetchAllAssociative();
$addIdHref = function ($data) {
foreach ($data as &$row) {
$row['id'] = "<a href=\"javascript:nw('productsBatches',{$row['id']}, '')\">{$row['id']}</a>";
}
return $data;
};
if ($nearExpiration) {
$errors[] = new HtmlDataMessage(sprintf(translate('batchesNearExpiration', 'productsBatches', false, true), count($nearExpiration)), $addIdHref($nearExpiration));
}
if ($afterExpiration) {
$errors[] = new HtmlDataMessage(sprintf(translate('batchesAfterExpiration', 'productsBatches', false, true), count($afterExpiration)), $addIdHref($afterExpiration));
}
return $errors;
}
public function isEnabled(): bool
{
return findModule(\Modules::PRODUCTS_BATCHES) && findModule(\Modules::WAREHOUSE);
}
}

View File

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

View File

@@ -0,0 +1,31 @@
<?php
namespace KupShop\ProductsBatchesBundle\Query;
use Query\QueryBuilder;
class BatchesQuery
{
public static function joinStoreBatches(): callable
{
return function (QueryBuilder $qb) {
$subtractBatches = sqlQueryBuilder()->select("SUM(JSON_EXTRACT(si2.custom_data, CONCAT('$.batch_numbers.', pb2.id)))")
->from('stores_items', 'si2')
->innerJoin('si2',
'products_batches',
'pb2',
"si2.id_product = pb2.id_product AND (si2.id_variation = pb2.id_variation OR (si2.id_variation IS NULL AND pb2.id_variation IS NULL)) AND
JSON_CONTAINS_PATH(si2.custom_data, 'one', CONCAT('$.batch_numbers.', pb2.id))")
->where('si2.id = si.id')->getSQL();
$qb->addSelect('pb.code batch_code',
' pb.date_expiry batch_expiry',
"COALESCE(JSON_EXTRACT(si.custom_data, CONCAT('$.batch_numbers.', pb.id)), si.quantity - COALESCE(({$subtractBatches}), 0)) AS batch_quantity")
->innerJoin('si',
'(SELECT id, id_product, id_variation, code, date_expiry FROM products_batches UNION SELECT null,null,null,null,null)',
'pb',
"(si.id_product = pb.id_product AND (si.id_variation = pb.id_variation OR (si.id_variation IS NULL AND pb.id_variation IS NULL)) AND
JSON_CONTAINS_PATH(si.custom_data, 'one', CONCAT('$.batch_numbers.', pb.id))) OR pb.id IS NULL");
};
}
}

View File

@@ -0,0 +1,7 @@
services:
_defaults:
autowire: true
autoconfigure: true
KupShop\ProductsBatchesBundle\:
resource: ../../{AdminRegister,EventListener,Util,Inspections}

View File

@@ -0,0 +1,24 @@
<?php
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\ProductsBatchesBundle\Util\ProductsBatchesUtil;
function smarty_function_get_product_nearest_expiration_batch($params, &$smarty)
{
if (empty($params['id_product'])) {
throw new InvalidArgumentException("Missing parameter 'id_product' in smarty function 'get_product_batches'!");
}
/**
* @var ProductsBatchesUtil $batchesUtil
*/
$batchesUtil = ServiceContainer::getService(ProductsBatchesUtil::class);
$batches = $batchesUtil->getProductNearestExpirationBatches($params['id_product']);
if (!empty($params['assign'])) {
$smarty->assign($params['assign'], $batches);
} else {
return $batches;
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace KupShop\ProductsBatchesBundle\Resources\upgrade;
class ProductBatchesUpgrade extends \UpgradeNew
{
public function check_productBatchesTable()
{
return $this->checkTableExists('products_batches');
}
/** Add products_batches table */
public function upgrade_productBatchesTable()
{
sqlQuery('CREATE TABLE products_batches
(
id INT AUTO_INCREMENT,
id_product INT(11) NOT NULL,
id_variation INT(11) NULL,
code VARCHAR(100) NOT NULL,
date_expiry DATETIME NULL,
CONSTRAINT products_batches_pk
PRIMARY KEY (id),
CONSTRAINT products_batches_unique
UNIQUE (id_product, code),
CONSTRAINT products_batches_products_id_fk
FOREIGN KEY (id_product) REFERENCES products (id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT products_batches_products_variations_id_fk
FOREIGN KEY (id_variation) REFERENCES products_variations (id)
ON UPDATE CASCADE ON DELETE SET NULL
);
');
$this->upgradeOK();
}
public function check_productBatchesWarehouseProducts()
{
return findModule(\Modules::WAREHOUSE) && $this->checkColumnExists('warehouse_products', 'id_product_batch');
}
/** Add products_batches column into warehouse */
public function upgrade_productBatchesWarehouseProducts()
{
sqlQuery('ALTER TABLE warehouse_products ADD COLUMN id_product_batch INT DEFAULT NULL');
sqlQuery('ALTER TABLE warehouse_log ADD COLUMN id_product_batch INT DEFAULT NULL');
$this->upgradeOK();
}
public function check_productBatchesWarehouseConstraint()
{
return findModule(\Modules::WAREHOUSE) && $this->checkForeignKeyExists('warehouse_products', 'id_product_batch');
}
/** Add id_product_batch foreign key into warehouse_products */
public function upgrade_productBatchesWarehouseConstraint()
{
sqlQuery('alter table warehouse_products
add constraint warehouse_products_products_batches_id_fk
foreign key (id_product_batch) references products_batches (id)
on update cascade;
');
$this->upgradeOK();
}
public function check_productBatchesWarehouseRemoveIndexWarehouse_products()
{
return findModule(\Modules::WAREHOUSE) && !$this->checkIndexNameExists('warehouse_products', 'warehouse_products');
}
/** Modify warehouse_products remove old index which is replaced with integrity_unique */
public function upgrade_productBatchesWarehouseRemoveIndexWarehouse_products()
{
sqlQuery('create index warehouse_products_id_product_index on warehouse_products (id_product);');
sqlQuery('drop index warehouse_products on warehouse_products;');
$this->upgradeOK();
}
public function check_productProductsTableRequire()
{
return $this->checkColumnExists('products', 'batch_number_require');
}
/** Add batch_number_require to products table */
public function upgrade_productProductsTableRequire()
{
sqlQuery("alter table products add batch_number_require enum('Y', 'N') default 'N' null;");
$this->upgradeOK();
}
public function check_tableProductBatchesUnique()
{
return findModule(\Modules::WAREHOUSE) && !$this->checkIndexNameExists('products_batches', 'products_batches_unique');
}
/** Modify products_batches remove products_batches_unique which is replaced with integrity_unique */
public function upgrade_tableProductBatchesUnique()
{
sqlQuery('create index products_batches_id_product_index on products_batches (id_product);');
sqlQuery('drop index products_batches_unique on products_batches;');
sqlQuery('create index products_batches_code_index on products_batches (code);');
$this->upgradeOK();
}
public function check_tableProductBatchesOnDeleteConstraint()
{
return $this->checkConstraintRule('products_batches', 'products_batches_products_variations_id_fk', 'CASCADE');
}
public function upgrade_tableProductBatchesOnDeleteConstraint()
{
sqlQuery('alter table products_batches
drop foreign key products_batches_products_variations_id_fk');
sqlQuery('alter table products_batches
add constraint products_batches_products_variations_id_fk
foreign key (id_variation) references products_variations (id)
on update cascade on delete cascade');
}
public function check_fixWarehouseProductsRemovePersistentIntegirityUnique()
{
return findModule(\Modules::WAREHOUSE) && !$this->checkColumnExists('warehouse_products',
'integrity_unique_product_variation_position_batch');
}
/** Remove warehouse_products persistent integrity_unique */
public function upgrade_fixWarehouseProductsRemovePersistentIntegirityUnique()
{
sqlQuery('alter table warehouse_products
drop column integrity_unique_product_variation_position_batch; ');
$this->upgradeOK();
}
public function check_addWarehouseProductsUniqueVirtualColumn()
{
return findModule(\Modules::WAREHOUSE) && $this->checkColumnExists('warehouse_products',
'integrity_unique');
}
/** Add warehouse_products virtual integrity_unique */
public function upgrade_addWarehouseProductsUniqueVirtualColumn()
{
sqlQuery("ALTER TABLE warehouse_products ADD integrity_unique varchar(50) as (CONCAT(id_product, '-', COALESCE(id_variation, ''), '-', id_position, '-',
COALESCE(id_product_batch, ''))) VIRTUAL UNIQUE; ");
$this->upgradeOK();
}
public function check_fixProductsBatchesUniqueVirtualColumn()
{
return !$this->checkColumnExists('products_batches',
'integrity_unique_product_variation_code');
}
/** Remove products_batches persistent integrity_unique */
public function upgrade_fixProductsBatchesUniqueVirtualColumn()
{
sqlQuery('alter table products_batches
drop column integrity_unique_product_variation_code; ');
$this->upgradeOK();
}
public function check_addProductsBatchesUniqueVirtualColumn()
{
return $this->checkColumnExists('products_batches', 'integrity_unique');
}
/** Add products_batches virtual integrity_unique */
public function upgrade_addProductsBatchesUniqueVirtualColumn()
{
sqlQuery("ALTER TABLE products_batches
ADD COLUMN integrity_unique VARCHAR(50) AS
(CONCAT(id_product, '-', COALESCE(id_variation, ''), '-', code)) VIRTUAL UNIQUE;"
);
$this->upgradeOK();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace KupShop\ProductsBatchesBundle\Resources\upgrade;
use Query\Operator;
class StoreBatchesUpgrade extends \UpgradeNew
{
protected $priority = 110;
public function check_storesCustomData()
{
return $this->checkModule(\Modules::STORES) && $this->checkColumnExists('stores_items', 'custom_data');
}
public function upgrade_storesCustomData()
{
sqlQuery("
alter table stores_items
add custom_data text default '' null;");
}
public function check_storesBatchesMoveToCustomData()
{
return $this->checkModule(\Modules::STORES) && !$this->checkColumnExists('stores_items', 'batches') && !$this->checkColumnExists('stores_items', 'custom_data');
}
public function upgrade_storesBatchesMoveToCustomData()
{
$storeItems = sqlQueryBuilder()->select('id, batches')
->from('stores_items', 'si')->execute()->fetchAllAssociative();
foreach ($storeItems as $storeItem) {
try {
$batches = json_decode_strict($storeItem['batches'], true);
} catch (\Exception $e) {
$batches = [];
}
if (!empty($batches)) {
sqlQueryBuilder()->update('stores_items')
->set('custom_data', ':customData')
->setParameter('customData', json_encode(['batch_numbers' => $batches]))
->where(Operator::equals(['id' => $storeItem['id']]))
->execute();
}
}
sqlQuery('alter table stores_items
drop column batches;
');
$this->upgradeOK();
}
}

View File

@@ -0,0 +1,49 @@
{
"products_batches": [
{
"id": 1,
"id_product": 1,
"id_variation": null,
"code": "200124",
"date_expiry": "2024-01-20 00:00:00"
},
{
"id": 2,
"id_product": 2,
"id_variation": 10,
"code": "2/22",
"date_expiry": "2022-02-28 00:00:00"
}
], "stores_transfers": [
{
"id": 1,
"id_store_from": 1,
"id_store_to": 1,
"date_created": "2021-09-09 13:44:10",
"state": "R",
"note": null,
"checkouts": "",
"id_position": null
}
]
, "stores_transfers_items": [
{
"id": 1,
"id_stores_transfer": 1,
"id_product": 1,
"id_variation": null,
"quantity": 5,
"custom_data": ""
},
{
"id": 2,
"id_stores_transfer": 1,
"id_product": 4,
"id_variation": 1,
"quantity": 3,
"custom_data": ""
}
]
}

View File

@@ -0,0 +1,216 @@
<?php
namespace KupShop\ProductsBatchesBundle\Tests;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\ProductsBatchesBundle\Util\ProductsBatchesUtil;
use Query\Operator;
class ProductsBatchesCustomDataTest extends \DatabaseTestCase
{
/**
* @var ProductsBatchesUtil
*/
private $productBatchesUtil;
protected function setUp(): void
{
$this->productBatchesUtil = ServiceContainer::getService(ProductsBatchesUtil::class);
parent::setUp();
}
public function testCreateBatch()
{
$batches = $this->createBatches();
self::assertEquals(4, $batches[0]['id_product']);
self::assertEquals(1, $batches[0]['id_variation']);
self::assertEquals('1234', $batches[0]['code']);
self::assertEquals('2022-02-10 00:00:00', $batches[0]['date_expiry']);
$alreadyExists = $this->productBatchesUtil->createBatchNumber([
'id_product' => 1,
'code' => '4321',
'date_expiry' => '2019-09-1 00:00:00',
]);
unset($alreadyExists['id']);
self::assertEquals([
'id_product' => 1,
'id_variation' => null,
'code' => '4321',
'date_expiry' => '2022-03-28 00:00:00',
], $alreadyExists);
// getBatchNumber returns same value as createBatchNumber
foreach ($batches as $batch) {
self::assertEquals($this->productBatchesUtil->getBatchNumber($batch['id']), $batch);
}
}
public function testBatchesInsertCustomData()
{
$where1 = [
'id_stores_transfer' => 1,
'id_product' => 1,
'id_variation' => null,
];
$where2 = [
'id_stores_transfer' => 1,
'id_product' => 4,
'id_variation' => 1,
];
$this->productBatchesUtil->addBatchesToCustomData('stores_transfers_items', $where1, [1 => 3]);
$this->productBatchesUtil->addBatchesToCustomData('stores_transfers_items', $where1, [2 => 2]);
$this->productBatchesUtil->addBatchesToCustomData('stores_transfers_items', $where2, [3 => 1, 8 => 2]);
$customData = $this->getCustomData($where1);
self::assertEquals(3, $customData['batch_numbers'][1]);
self::assertEquals(2, $customData['batch_numbers'][2]);
$customData = $this->getCustomData($where2);
self::assertEquals(1, $customData['batch_numbers'][3]);
self::assertEquals(2, $customData['batch_numbers'][8]);
$this->productBatchesUtil->addBatchesToCustomData('stores_transfers_items', $where1, [1 => -1, 2 => -3]);
$customData = $this->getCustomData($where1);
self::assertEquals(2, $customData['batch_numbers'][1]);
self::assertArrayNotHasKey(2, $customData['batch_numbers']);
$this->productBatchesUtil->addBatchesToCustomData('stores_transfers_items', $where1, [2 => -1]);
$customData = $this->getCustomData($where1);
self::assertArrayNotHasKey(2, $customData['batch_numbers']);
$this->productBatchesUtil->setBatchesToCustomData('stores_transfers_items', $where1, [1 => 20]);
$this->productBatchesUtil->setBatchesToCustomData('stores_transfers_items', $where1, [2 => 15]);
$this->productBatchesUtil->setBatchesToCustomData('stores_transfers_items', $where2, [8 => -1]);
$customData = $this->getCustomData($where1);
self::assertEquals(20, $customData['batch_numbers'][1]);
self::assertEquals(15, $customData['batch_numbers'][2]);
$customData = $this->getCustomData($where2);
self::assertArrayNotHasKey(8, $customData['batch_numbers']);
$this->productBatchesUtil->setBatchesToCustomData('stores_transfers_items', $where2, [8 => -5]);
$customData = $this->getCustomData($where2);
self::assertArrayNotHasKey(8, $customData['batch_numbers']);
$this->productBatchesUtil->setBatchesToCustomData('stores_transfers_items', $where1, [1 => 1, 2 => -1]);
$customData = $this->getCustomData($where1);
self::assertEquals(1, $customData['batch_numbers'][1]);
self::assertArrayNotHasKey(2, $customData['batch_numbers']);
$this->productBatchesUtil->clearBatchesCustomData('stores_transfers_items', $where1);
$customData = $this->getCustomData($where1);
self::assertEmpty($customData['batch_numbers']);
$this->productBatchesUtil->clearBatchesCustomData('stores_transfers_items', $where2);
$customData = $this->getCustomData($where2);
self::assertEmpty($customData['batch_numbers']);
}
public function data_BatchesInitCustomData(): array
{
return [
['addBatchesToCustomData'],
['setBatchesToCustomData'],
];
}
/**
* @dataProvider data_BatchesInitCustomData
*/
public function testBatchesInitCustomData($method)
{
$where = [
'id_stores_transfer' => 1,
'id_product' => 1,
'id_variation' => null,
];
$this->setCustomData('ABC', $where);
$this->productBatchesUtil->$method('stores_transfers_items', $where, [1 => 3]);
self::assertEquals(3, $this->getCustomData($where)['batch_numbers'][1]);
$this->setCustomData('', $where);
$this->productBatchesUtil->$method('stores_transfers_items', $where, [1 => 3]);
self::assertEquals(3, $this->getCustomData($where)['batch_numbers'][1]);
$this->setCustomData('{}', $where);
$this->productBatchesUtil->$method('stores_transfers_items', $where, [1 => 3]);
self::assertEquals(3, $this->getCustomData($where)['batch_numbers'][1]);
$this->setCustomData('{"something": 555}', $where);
$this->productBatchesUtil->$method('stores_transfers_items', $where, [1 => 3]);
self::assertEquals(555, $this->getCustomData($where)['something']);
self::assertEquals(3, $this->getCustomData($where)['batch_numbers'][1]);
}
public function testClearCustomData()
{
$where = [
'id_stores_transfer' => 1,
'id_product' => 1,
'id_variation' => null,
];
$this->setCustomData('ABC', $where);
$this->productBatchesUtil->clearBatchesCustomData('stores_transfers_items', $where);
self::assertEmpty($this->getCustomData($where));
$this->setCustomData('', $where);
$this->productBatchesUtil->clearBatchesCustomData('stores_transfers_items', $where);
self::assertEmpty($this->getCustomData($where));
$this->setCustomData('{"batch_numbers": {"56": 1}}', $where);
$this->productBatchesUtil->clearBatchesCustomData('stores_transfers_items', $where);
self::assertEmpty($this->getCustomData($where)['batch_numbers']);
$this->setCustomData('{"something": 555, "batch_numbers": {"56": 1}}', $where);
$this->productBatchesUtil->clearBatchesCustomData('stores_transfers_items', $where);
self::assertEmpty($this->getCustomData($where)['batch_numbers']);
self::assertEquals(555, $this->getCustomData($where)['something']);
}
public function getDataSet()
{
return $this->getJsonDataSetFromFile();
}
private function setCustomData($string, $where)
{
sqlQueryBuilder()->update('stores_transfers_items')->directValues(['custom_data' => $string])
->where(Operator::equalsNullable($where))->execute();
}
private function getCustomData($where)
{
return json_decode(sqlQueryBuilder()->select('custom_data')->from('stores_transfers_items')
->where(Operator::equalsNullable($where))->execute()->fetchOne(),
true);
}
private function createBatches()
{
$batches = [];
$batches[] = $this->productBatchesUtil->createBatchNumber([
'id_product' => 4,
'id_variation' => 1,
'code' => '1234',
'date_expiry' => '2022-02-10 00:00:00',
]);
$batches[] = $this->productBatchesUtil->createBatchNumber([
'id_product' => 1,
'code' => '4321',
'date_expiry' => '2022-03-28 00:00:00',
]);
return $batches;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace KupShop\ProductsBatchesBundle\Util;
use Doctrine\DBAL\ParameterType;
use KupShop\CatalogBundle\ProductList\ProductCollection;
use KupShop\KupShopBundle\Util\Database\QueryHint;
use KupShop\StoresBundle\Utils\StoresInStore;
use Query\Operator;
class ProductsBatchesUtil
{
public function getBatchNumber($id)
{
return sqlQueryBuilder()->select('id', 'id_product', 'id_variation', 'code', 'date_expiry')
->from('products_batches')
->where(Operator::equals(['id' => $id]))->execute()->fetch();
}
public function getProductNearestExpirationBatches($idProduct)
{
$subQuery = sqlQueryBuilder()->select('pb2.id, MIN(pb2.date_expiry) min_date_expiry')
->from('products_batches', 'pb2')
->innerJoin('pb2', 'warehouse_products', 'wp2', 'pb2.id = wp2.id_product_batch')
->innerJoin('wp2', 'warehouse_positions', 'wpos2', 'wp2.id_position = wpos2.id')
->where('pb2.date_expiry IS NOT NULL')
->andWhere('wp2.pieces > 0')
->andWhere("wpos2.code NOT LIKE '%BOX%'")
->groupBy('pb2.id');
$qb = sqlQueryBuilder()->select('pb.id', 'pb.id_variation', 'pb.code', 'pb.date_expiry')
->from('products_batches', 'pb')
->innerJoin('pb', '('.$subQuery->getSQL().')', 'pb2', 'pb.id = pb2.id AND pb.date_expiry = pb2.min_date_expiry')
->where(Operator::equals(['pb.id_product' => $idProduct]))
->groupBy('pb.id_variation');
$result = [];
foreach ($qb->execute() as $row) {
$vId = $row['id_variation'];
$batch = [
'id' => $row['id'],
'code' => $row['code'],
'date_expiry' => $row['date_expiry'],
];
if ($vId) {
$result['variations'] = $result['variations'] ?? [];
$result['variations'][$vId] = $batch;
} else {
$result['product'] = $batch;
}
}
return $result;
}
public function createBatchNumber($data)
{
$key = [
'id_product' => $data['id_product'],
'id_variation' => $data['id_variation'] ?? null,
'code' => $data['code'],
];
$alreadyExists = QueryHint::withRouteToMaster(function () use ($key) {
return sqlQueryBuilder()->select('id', 'id_product', 'id_variation', 'code', 'date_expiry')
->from('products_batches')
->where(Operator::equalsNullable($key))->execute()->fetchAssociative();
});
if (!$alreadyExists) {
$key['date_expiry'] = $data['date_expiry'];
$key['id'] = sqlGetConnection()->transactional(function () use ($key) {
sqlQueryBuilder()->insert('products_batches')
->directValues($key)
->execute();
return sqlInsertId();
});
return $key;
} else {
return $alreadyExists;
}
}
/**
* For each batch from $batches in format [$idBatch => $amount, $idBatch2=> $amount2] it updates custom_data batches.
*
* If batch already exists in the custom_data, it only increases/decreases its amount. Or removes the batch when its amount would be <= 0.
* If batch doesn't exist, it creates the batch in the JSON structure.
* If custom_data doesn't contain the batch_numbers structure, it creates the batch_numbers structure next to the previous content of the custom_data.
* If custom data is null or empty, it creates the structure for the batch_numbers.
*
* @param array $where condition for update
* @param array $batches batches numbers in format [$idBatch => $amount, $idBatch2=> $amount2]
*
* @return void
*/
public function addBatchesToCustomData(string $tableName, array $where, array $batches, $columnName = 'custom_data')
{
$this->modifyBatchesToCustomData($tableName, $where, $batches, true, $columnName);
}
/**
* Same as method addBatchesToCustomData, but batch amount is set, not incremented.
*
* @param array $where condition for update
* @param array $batches batches numbers in format [$idBatch => $amount, $idBatch2=> $amount2]
*
* @return void
*/
public function setBatchesToCustomData(string $tableName, array $where, array $batches, $columnName = 'custom_data')
{
$this->modifyBatchesToCustomData($tableName, $where, $batches, false, $columnName);
}
protected function modifyBatchesToCustomData(string $tableName, array $where, array $batches, bool $incrementActualValue, $columnName)
{
if ($incrementActualValue) {
$futureValue = "JSON_EXTRACT({$columnName}, CONCAT('$.batch_numbers.', :batchId)) + :batchAmount";
} else {
$futureValue = ':batchAmount';
}
foreach ($batches as $idBatch => $amountBatch) {
sqlQueryBuilder()->update($tableName)->where(Operator::equalsNullable($where))
->set($columnName,
"
IF(JSON_CONTAINS_PATH(NULLIF({$columnName}, ''), 'one', CONCAT('$.batch_numbers.', :batchId)),
IF({$futureValue} > 0,
JSON_SET({$columnName}, CONCAT('$.batch_numbers.', :batchId), {$futureValue}),
JSON_REMOVE({$columnName}, CONCAT('$.batch_numbers.', :batchId))
)
,
JSON_REPLACE(
IF(JSON_CONTAINS_PATH(NULLIF({$columnName}, ''), 'one', '$.batch_numbers'),
{$columnName},
IF(JSON_TYPE(NULLIF({$columnName}, '')) = 'OBJECT', JSON_INSERT({$columnName}, '$.batch_numbers', JSON_OBJECT()),
JSON_OBJECT('batch_numbers', JSON_OBJECT()))
)
, '$.batch_numbers',
JSON_MERGE_PATCH(COALESCE(JSON_EXTRACT(NULLIF({$columnName}, ''), '$.batch_numbers'), JSON_OBJECT()),
IF(:batchAmount > 0, JSON_OBJECT(:batchId, :batchAmount), JSON_OBJECT())))
)
")
->setParameter('batchId', $idBatch)
->setParameter('batchAmount', (int) $amountBatch, ParameterType::INTEGER)->execute();
}
}
/**
* Clear batch numbers object from custom_data of the table.
*
* @return void
*/
public function clearBatchesCustomData(string $tableName, array $where, $columnName = 'custom_data')
{
sqlQueryBuilder()->update($tableName)
->set($columnName, "JSON_SET({$columnName}, CONCAT('$.batch_numbers'), JSON_OBJECT())")
->where(Operator::equalsNullable($where))
->execute();
}
public function getProductBatches(ProductCollection $products, bool $withQuantity = false, bool $byVariations = false): ?array
{
$qb = sqlQueryBuilder()->select('pb.id, pb.id_product, pb.id_variation, pb.code, pb.date_expiry')
->from('products_batches', 'pb')
->where(Operator::inIntArray($products->getProductIds(), 'pb.id_product'))
->groupBy('pb.code');
if ($withQuantity) {
$qb->addSelect("
COALESCE((SELECT SUM(DISTINCT JSON_EXTRACT(si.custom_data, CONCAT('$.batch_numbers.', pb.id)))
FROM stores_items si LEFT JOIN stores s ON si.id_store = s.id
WHERE JSON_EXTRACT(si.custom_data, CONCAT('$.batch_numbers.', pb.id)) IS NOT NULL
AND si.id_product = pb.id_product AND si.id_variation <=> pb.id_variation AND s.type = :externalStoreType), 0)
+ COALESCE(SUM(DISTINCT wp.pieces), 0) AS batch_quantity
")
->setParameter('externalStoreType', StoresInStore::TYPE_EXTERNAL_STORE);
$qb->leftJoin('pb', 'warehouse_products', 'wp', 'pb.id = wp.id_product_batch');
}
if ($byVariations) {
$qb->addGroupBy('pb.id_variation');
}
return $qb->execute()->fetchAllAssociative();
}
}