Files
kupshop/bundles/KupShop/CheckAppBundle/Util/StockInWorker.php
2025-08-02 16:30:27 +02:00

609 lines
25 KiB
PHP

<?php
namespace KupShop\CheckAppBundle\Util;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use KupShop\CatalogBundle\Util\Product\ProductPriceWeigher;
use KupShop\CheckAppBundle\Exception\StockInException;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\KupShopBundle\Util\EANValidator;
use KupShop\KupShopBundle\Util\LoggingContext;
use KupShop\KupShopBundle\Util\StringUtil;
use KupShop\ProductsBatchesBundle\Util\ProductsBatchesUtil;
use KupShop\StoresBundle\Utils\StoresInStore;
use Query\Operator;
use Symfony\Contracts\Service\Attribute\Required;
class StockInWorker
{
use \DatabaseCommunication;
protected $newStockInId = [];
/**
* @var ProductPriceWeigher
*/
protected $productPriceWeigher;
protected ?StoresInStore $storesInStore;
#[Required]
public LoggingContext $loggingContext;
/**
* @required
*/
public function setProductPriceWeigher(ProductPriceWeigher $productPriceWeigher)
{
$this->productPriceWeigher = $productPriceWeigher;
}
#[Required]
public function setStoresInStore(?StoresInStore $storesInStore)
{
$this->storesInStore = $storesInStore;
}
public function getStockIn($id_stock_in)
{
return sqlQueryBuilder()->select('code')->from('stock_in')
->where(Operator::equals(['id' => $id_stock_in]))
->execute()->fetch();
}
/**
* @return array
*
* @throws \Exception
*/
public function getStockInData($id_stock_in)
{
if (strpos($id_stock_in, '_') !== false) {
$ids = array_reverse(explode('_', $id_stock_in));
} else {
$ids = [$id_stock_in];
}
$stockInsItems = [];
$index = 0;
foreach ($ids as $id) {
$qb = sqlQueryBuilder()
->select('sii.id as id, CONCAT_WS(" ", p.title, pv.title) as title, SUM(sii.quantity) as quantity, COALESCE(pv.code, p.code) as code,
COALESCE(pv.ean, p.ean) as ean, sii.id_product, sii.id_variation, COALESCE(pv.note, p.note) as note')
->from('stock_in_items', 'sii')
->join('sii', 'stock_in', 'si', "si.id=sii.id_stock_in AND si.id_index='future'")
->leftJoin('sii', 'products', 'p', 'p.id=sii.id_product')
->leftJoin('sii', 'products_variations', 'pv', 'pv.id=sii.id_variation')
->where(Operator::equals(['id_stock_in' => $id]))
->addSelect(\Query\Product::withOneProductPhotoId(true))
->groupBy('sii.id_product, sii.id_variation')
->orderBy('sii.id', 'DESC');
if (findModule(\Modules::PRODUCTS_SERIAL_NUMBERS)) {
$qb->addSelect('p.serial_number_require');
}
if (findModule(\Modules::PRODUCTS_BATCHES)) {
$qb->addSelect('p.batch_number_require');
}
$items = $qb->execute()->fetchAll();
foreach ($items as $key => $item) {
if (empty($item['id_product'])) {
continue;
}
$item['quantity'] = (int) $item['quantity'];
$item['ean'] = formatEAN($item['ean']);
if ($item['id_photo']) {
$item['image'] = getImage($item['id_photo'], null, null, 3)['src'];
}
$item['stock_ins_count'] = sqlQueryBuilder()->select('COUNT(sii.id) as stock_in_count')
->from('stock_in_items', 'sii')
->leftJoin('sii', 'stock_in', 'si', 'si.id = sii.id_stock_in')
->where(
Operator::equalsNullable(
[
'sii.id_product' => $item['id_product'],
'sii.id_variation' => $item['id_variation'],
]
)
)
->andWhere('si.id_index != "future"')
->execute()->fetchColumn();
if (findModule(\Modules::PRODUCTS_BATCHES)) {
$item['batches_numbers'] = sqlQueryBuilder()->select('id, code', 'date_expiry')->from('products_batches', 'pb')
->where(
Operator::equalsNullable(
[
'pb.id_product' => $item['id_product'],
'pb.id_variation' => $item['id_variation'],
]
)
)
->orderBy('date_expiry', 'DESC')->addOrderBy('id', 'DESC')
->execute()->fetchAll();
}
if (empty($stockInsItems[$item['id_product'].'_'.$item['id_variation']])) {
$addData = $this->getAdditionalData($item['id_product'], $item['id_variation']);
if ($addData) {
$item = array_merge($item, $addData);
}
$stockInsItems[$item['id_product'].'_'.$item['id_variation']] = $item;
} else {
$stockInsItems[$item['id_product'].'_'.$item['id_variation']]['quantity'] += (int) $item['quantity'];
}
}
}
$data = $stockInsItems;
usort($data, function ($a, $b) {
return empty($b['ean']) <=> empty($a['ean']);
});
foreach ($data as &$item) {
$item['index'] = $index++;
}
return array_values($data);
}
protected function getAdditionalData($id_product, $id_variation): array
{
$data = sqlQueryBuilder()
->select('GROUP_CONCAT(pos.ean SEPARATOR ";") as supplier_eans, GROUP_CONCAT(pos.code SEPARATOR ";") as supplier_codes')
->from('products_of_suppliers', 'pos')
->where(Operator::equalsNullable(['pos.id_product' => $id_product, 'pos.id_variation' => $id_variation]))
->groupBy('pos.id_product, pos.id_variation')
->execute()->fetch();
if ($data) {
$data['supplier_eans'] = explode(';', $data['supplier_eans']);
$data['supplier_codes'] = explode(';', $data['supplier_codes']);
} else {
$data = [];
}
return $data;
}
/**
* @return bool
*
* @throws \Exception
* @throws \Throwable
*/
public function submitStockIn($id_stock_in, $items)
{
$checkedItems = [];
// Jen si to přeindexuju pro lepší práci
foreach ($items as $item) {
$checkedItems[$item->id_product.'_'.$item->id_variation] = $item;
}
$stockInIds = array_unique(explode('_', $id_stock_in));
sqlGetConnection()->transactional(
function () use ($stockInIds, $checkedItems) {
// Lock stock all stock-ins to avoid concurent updates
$stockIns = sqlQueryBuilder()->select('id', 'number', 'id_index')
->from('stock_in')
->where(Operator::inIntArray($stockInIds, 'id'))
->orderBy('id', 'DESC')
->forUpdate()
->execute()->fetchAll();
$nonFutures = array_filter($stockIns, function ($stockIn) {return $stockIn['id_index'] != 'future'; });
if ($nonFutures) {
throw new StockInException('Naskladnění již proběhlo s čísly faktur: '.join(', ', array_map(f_field('number'), $nonFutures)));
}
foreach ($stockInIds as $stockInId) {
$number = returnSQLResult('SELECT MAX(number)+1 FROM stock_in WHERE id_index="invoice"');
if (empty($number)) {
$number = 1;
}
sqlQueryBuilder()->update('stock_in')
->set('id_index', ':invoice')
->set('number', $number)
->set('date_stock_in', 'NOW()')
->setParameter('invoice', 'invoice')
->where(Operator::equals(['id' => $stockInId]))
->execute();
// Načtení položek + kod a dodavatel faktury, aby pri presunu polozky do nove faktury
// sem vzdy vedel dodavatele i kod puvodni faktury - musi se totiz nejdriv nova faktura vytvorit zejo
$stockInItems = sqlQueryBuilder()
->select('sii.*, si.id_supplier, si.code as stock_in_code, si.data')
->from('stock_in_items', 'sii')
->leftJoin('sii', 'stock_in', 'si', 'sii.id_stock_in=si.id')
->where(Operator::equals(['sii.id_stock_in' => $stockInId]))
->execute()
->fetchAll();
foreach ($stockInItems as $itemRow) {
$checkedItem = &$checkedItems[$itemRow['id_product'].'_'.$itemRow['id_variation']];
// Pokud je v naskladnění položka bez id_produktu, je to nový produkt - ještě nemá založenou kartu,
// tak mu nastavím checked 0 aby se přesunul do nového budoucího naskladnění
if (empty($itemRow['id_product'])) {
if (!$checkedItem) {
$checkedItems[$itemRow['id_product'].'_'.$itemRow['id_variation']] = new \stdClass();
}
$checkedItem->checked = 0;
}
// Pokud je daný produkt chcked, pokračuju, když není, můžu ho z dané faktury rovnou smazat
if (!empty($checkedItem->checked)) {
// Pokud je méně napípnuto než má být, aktualizuju naskladnění a zruším checked položku, tím pádem u
// dalšího naskladnění už dochází k její odmazávání
if ($checkedItem->checked < $itemRow['quantity']) {
sqlQueryBuilder()
->update('stock_in_items')
->set('quantity', $checkedItem->checked)
->where(Operator::equals(['id' => $itemRow['id']]))
->execute();
$inStoredPieces = $checkedItem->checked;
// Do nove faktury vkladam jen zbytek kusu, proto tu odectu uz napipane
$itemRow['quantity'] = $itemRow['quantity'] - $checkedItem->checked;
$this->insertIntoNewStockIn($itemRow);
unset($checkedItems[$itemRow['id_product'].'_'.$itemRow['id_variation']]);
} else {
// Pokud je napípnuto víc, pouze odečtu a nechám další napípnuté pro další naskladnění
$checkedItem->checked -= $inStoredPieces = $itemRow['quantity'];
}
if (findModule(\Modules::PRODUCTS_BATCHES) && ($checkedItem->batch_number_require ?? 'N') == 'Y') {
$productsBatchesUtil = ServiceContainer::getService(ProductsBatchesUtil::class);
$batchNumber = null;
if ($checkedItem->batchNumber->id ?? false) {
$batchNumber = $productsBatchesUtil->getBatchNumber($checkedItem->batchNumber->id);
}
if (!$batchNumber) {
$batchNumber = $productsBatchesUtil->createBatchNumber([
'id_product' => $checkedItem->id_product,
'id_variation' => $checkedItem->id_variation,
'code' => $checkedItem->batchNumber->code,
'date_expiry' => $checkedItem->batchNumber->date_expiry,
]);
}
$itemRow['batchNumber'] = $batchNumber;
}
$itemRow['id_product_batch'] = $itemRow['batchNumber']['id'] ?? null;
$this->storeIn($itemRow, $inStoredPieces);
$this->addSerialNumbers($checkedItem, $stockInId, $inStoredPieces);
if ($checkedItem->checked == 0) {
unset($checkedItems[$itemRow['id_product'].'_'.$itemRow['id_variation']]);
}
if (findModule(\Modules::STOCK_IN)) {
$this->updatePriceBuy($itemRow);
if (($checkedItem->quantity ?? 0) > 0) {
sqlQueryBuilder()
->update('products')
->set('date_stock_in', 'NOW()')
->andWhere(Operator::equals(['id' => $itemRow['id_product']]))
->execute();
}
}
} else {
sqlQueryBuilder()
->delete('stock_in_items')
->where(Operator::equals(['id' => $itemRow['id']]))
->execute();
$this->insertIntoNewStockIn($itemRow);
unset($checkedItems[$itemRow['id_product'].'_'.$itemRow['id_variation']]);
}
}
// unset kvůli referenci a následnému použití $checkedItem níže
unset($checkedItem);
}
// Pričtení napípnutých kusů navíc, které nebyly v žádné faktuře,
// od konce projíždím jednotlivé faktury a přičtu to k první, která má daný produkt
foreach (array_reverse($stockInIds) as $stockInId) {
$stockInItems = sqlQueryBuilder()
->select('sii.*, si.data')
->from('stock_in_items', 'sii')
->leftJoin('sii', 'stock_in', 'si', 'sii.id_stock_in=si.id')
->where(Operator::equals(['sii.id_stock_in' => $stockInId]))
->execute()
->fetchAll();
foreach ($stockInItems as $stockInItem) {
if (!empty($checkedItems[$stockInItem['id_product'].'_'.$stockInItem['id_variation']])) {
$checkedItem = $checkedItems[$stockInItem['id_product'].'_'.$stockInItem['id_variation']];
sqlQueryBuilder()
->update('stock_in_items')
->set('quantity', "quantity + {$checkedItem->checked}")
->where(Operator::equals(['id' => $stockInItem['id']]))
->execute();
$this->storeIn($stockInItem, $checkedItem->checked);
$this->addSerialNumbers($checkedItem, $stockInId, $checkedItem->checked);
unset($checkedItems[$stockInItem['id_product'].'_'.$stockInItem['id_variation']]);
}
}
}
if (!empty($checkedItems)) {
throw new \Exception('!! Něco se pohnojilo a zbyly nějaké kusy, s kterými se nic nestalo !!');
}
foreach ($stockInIds as $stockInId) {
$this->recalcTotalPrice($stockInId);
}
foreach ($this->newStockInId as $stockInId) {
$this->recalcTotalPrice($stockInId);
}
}
);
return true;
}
public function recalcTotalPrice($stockInId)
{
if (findModule(\Modules::STOCK_IN, \Modules::SUB_WEIGHTED_PURCHASE_PRICE)) {
// recalculate transport price by additional costs
sqlQuery('UPDATE stock_in s
SET transport_price = (
SELECT COALESCE(SUM(si.quantity*si.additional_costs), 0)
FROM stock_in_items si
WHERE si.id_stock_in=s.id
)
WHERE s.id=:id', ['id' => $stockInId]);
}
sqlQuery('UPDATE stock_in s
SET total_price=(
SELECT COALESCE(SUM(si.quantity*si.price), 0)+s.transport_price-s.discount
FROM stock_in_items si
WHERE si.id_stock_in=s.id
)
WHERE s.id=:id', ['id' => $stockInId]);
}
public function storeIn($itemRow, int $inStoredPieces): void
{
$itemRow['product'] = new \Product($itemRow['id_product']);
$itemRow['quantity'] = $inStoredPieces;
$this->productPriceWeigher->updatePurchasePrice($itemRow);
// do not storeIn if store is external
$updateWebStore = true;
if (findModule(\Modules::STORES) && ($store_id = json_decode($itemRow['data'] ?? '', true)['id_store'] ?? false)) {
$updateWebStore = !$this->isExternalStore($store_id);
$SQLfields = [
'id_store' => $store_id,
'id_product' => $itemRow['id_product'],
'id_variation' => $itemRow['id_variation'],
'quantity' => $itemRow['quantity'],
];
$this->loggingContext->activateStockIn($itemRow['id_stock_in'], function () use ($SQLfields) {
$this->storesInStore->updateStoreItem($SQLfields);
});
}
if ($updateWebStore) {
$itemRow['product']->storeIn($itemRow['id_variation'], $inStoredPieces);
}
}
protected function addSerialNumbers(&$item, $stockInId, $pieces)
{
if (!findModule(\Modules::PRODUCTS_SERIAL_NUMBERS) || $item->serial_number_require != 'Y') {
return;
}
if (count($item->serialNumbers) < $pieces) {
throw new StockInException('!! Chybí sériové čísla !!');
}
$serialNumbers = array_slice($item->serialNumbers, 0, $pieces);
$item->serialNumbers = array_slice($item->serialNumbers, $pieces);
try {
foreach ($serialNumbers as $serialNumber) {
sqlQueryBuilder()->insert('products_serial_numbers')->directValues(
[
'id_product' => $item->id_product,
'id_variation' => $item->id_variation,
'serial_number' => $serialNumber,
'id_stock_in' => $stockInId,
]
)->execute();
}
} catch (ConstraintViolationException $e) {
throw new StockInException('!! Duplicitní sériové číslo u produktu !!');
}
}
protected function insertIntoNewStockIn($stockInItemRow)
{
$stockInItemRow['id_stock_in'] = $this->getNewStockInId($stockInItemRow);
unset($stockInItemRow['id'], $stockInItemRow['id_supplier'], $stockInItemRow['stock_in_code'], $stockInItemRow['data']);
if ($existStockInItem = sqlQueryBuilder()
->select('*')
->from('stock_in_items')
->where(Operator::equalsNullable(
$stockInItemRow
))
->execute()
->fetch()
) {
sqlQueryBuilder()
->update('stock_in_items')
->set('quantity', "quantity + {$stockInItemRow['quantity']}")
->where(Operator::equals(['id' => $existStockInItem['id']]))
->execute();
} else {
sqlQueryBuilder()->insert('stock_in_items')
->directValues($stockInItemRow)
->execute();
}
}
protected function getNewStockInId($stockInItemRow)
{
if (!array_key_exists($stockInItemRow['id_supplier'], $this->newStockInId)) {
$oldStockIn = sqlQueryBuilder()
->select('*')
->from('stock_in')
->where(Operator::equals(['id' => $stockInItemRow['id_stock_in']]))
->execute()->fetch();
$oldStockInData = json_decode($oldStockIn['data'] ?? '', true) ?? [];
$code = $stockInItemRow['stock_in_code'];
foreach (range(1, 5) as $_) {
$code = StringUtil::duplicateCode($code, '#');
try {
$this->insertSQL('stock_in', [
'id_index' => 'future',
'code' => $code,
'date_created' => $oldStockIn['date_created'],
'id_supplier' => $stockInItemRow['id_supplier'],
'date_expiration' => $oldStockIn['date_expiration'],
'date_issued' => $oldStockIn['date_issued'],
'flags' => 'O',
'multiplier' => $oldStockIn['multiplier'],
'payment_method' => $oldStockIn['payment_method'],
'paid' => $oldStockIn['paid'],
'note' => $oldStockIn['note'],
'data' => json_encode(
array_merge($oldStockInData, [
'parent_stock_in' => $stockInItemRow['id_stock_in'],
])
),
]);
break;
} catch (UniqueConstraintViolationException) {
// Ignore duplicate code exception, generate new code and try again
}
}
$this->newStockInId[$stockInItemRow['id_supplier']] = sqlInsertId();
}
return $this->newStockInId[$stockInItemRow['id_supplier']];
}
protected function updatePriceBuy(array $item): void
{
if (findModule(\Modules::PRODUCTS, \Modules::SUB_PRICE_BUY)
&& findModule(\Modules::PRODUCTS, \Modules::SUB_PRICE_BUY) !== 'do_not_update'
&& !findModule(\Modules::STOCK_IN, \Modules::SUB_WEIGHTED_PURCHASE_PRICE)
) {
$qb = sqlQueryBuilder()
->update(($item['id_variation'] ?? false) ? 'products_variations' : 'products')
->set('price_buy', $item['price']);
if ($item['id_variation'] ?? false) {
$qb->andWhere(Operator::equals(['id' => $item['id_variation']]));
} else {
$qb->andWhere(Operator::equals(['id' => $item['id_product']]));
}
$qb->execute();
}
}
/**
* @return array
*/
public function addEan($stockInItem, $ean)
{
$stockInRow = sqlQueryBuilder()->select('id_variation, id_product')
->from('stock_in_items')
->where(Operator::equals(['id' => $stockInItem]))
->execute()
->fetch();
if (\Variations::eanExists($stockInRow['id_variation'], $ean, $stockInRow['id_product'])) {
$result = 'Tento EAN již existuje !';
} else {
$result = true;
sqlQueryBuilder()->update(empty($stockInRow['id_variation']) ? 'products' : 'products_variations')
->directValues([
'ean' => $ean,
])
->where(Operator::equals(['id' => empty($stockInRow['id_variation']) ? $stockInRow['id_product'] : $stockInRow['id_variation']]))
->execute();
}
return [
'result' => $result === true,
'msg' => $result,
'ean' => $ean,
];
}
public function generateEan($stockInItem)
{
$stockInRow = sqlQueryBuilder()->select('id_variation, id_product')
->from('stock_in_items')
->where(Operator::equals(['id' => $stockInItem]))
->execute()
->fetch();
if ($stockInRow['id_variation'] > 0) {
$ean = '201'.sprintf('%09d', $stockInRow['id_variation']);
} else {
$ean = '200'.sprintf('%09d', $stockInRow['id_product']);
}
$ean = $this->ean13_check_digit($ean);
sqlQueryBuilder()->update(empty($stockInRow['id_variation']) ? 'products' : 'products_variations')
->directValues(['ean' => $ean])
->where(Operator::equals(['id' => empty($stockInRow['id_variation']) ? $stockInRow['id_product'] : $stockInRow['id_variation']]))
->execute();
return [
'result' => true,
'msg' => 'Přidáno',
'ean' => $ean,
];
}
protected function ean13_check_digit($digits)
{
$digits = (string) $digits;
return $digits.EANValidator::getEAN13checkDigit($digits);
}
protected function isExternalStore(int $idStore): bool
{
return $this->storesInStore->getStores()[$idStore]['type'] == StoresInStore::TYPE_EXTERNAL_STORE;
}
}