logger = $logger; } public const LOCATION_TABLE = 1; public const LOCATION_BOX = 2; protected $box_position; public function returnError($errorMessage) { return [ 'result' => false, 'message' => $errorMessage, ]; } public function getStats($user) { $cfg = Config::get(); $userScore = sqlQueryBuilder() ->select('SUM((1 * GREATEST(wl.score_multiplier, wl2.score_multiplier))) as count') ->from('warehouse_log', 'wlog') ->leftJoin('wlog', 'warehouse_positions', 'wpos', 'wpos.id = wlog.old_id_position') ->leftJoin('wpos', 'warehouse_locations', 'wl', 'wl.id = wpos.id_location') ->leftJoin('wlog', 'warehouse_positions', 'wpos2', 'wpos2.id = wlog.new_id_position') ->leftJoin('wpos2', 'warehouse_locations', 'wl2', 'wl2.id = wpos2.id_location') ->where(Operator::equals(['wlog.id_admin' => $user->id])) ->andWhere('DATE(wlog.update_datetime) = CURDATE()') ->execute() ->fetchColumn(); $remainingOrders = null; if (isset($cfg['Order']['Status']['warehouse_remaining'])) { $remainingOrders = sqlQueryBuilder()->select('COUNT(o.id) as count') ->from('orders', 'o') ->leftJoin('o', 'warehouse_orders', 'wo', 'wo.id_order = o.id') ->where(Operator::inIntArray($cfg['Order']['Status']['warehouse_remaining'], 'o.status')) ->andWhere('wo.id IS NULL OR wo.date_finish IS NULL') ->execute() ->fetchColumn(); } $remainingStockIn = sqlQueryBuilder()->select('COUNT(*) as count') ->from('warehouse_products') ->where('id_position IN (SELECT id FROM warehouse_positions WHERE id_location = :id_location)') ->setParameter('id_location', self::LOCATION_TABLE) ->execute()->fetchColumn(); return [ 'result' => true, 'stats' => [ 'userScore' => $userScore ? $userScore : 0, 'remainingOrders' => $remainingOrders ? $remainingOrders : 0, 'remainingStockIn' => $remainingStockIn ? $remainingStockIn : 0, ], ]; } public function getAllTables() { $tables = sqlQueryBuilder() ->select('id, code, (SELECT COUNT(*) as count FROM warehouse_products WHERE id_position = id) as products_count') ->from('warehouse_positions') ->where(Operator::equals(['id_location' => self::LOCATION_TABLE])) ->execute()->fetchAll(); return $tables ?: []; } // DB converts some strings to 0 when comparing with int protected function isEanInvalid($ean) { return !is_numeric($ean) && intval($ean) === 0; } public function checkProductByEanAndLocation($ean, $id_location = null, $code_position = null, $batchId = false) { if ($this->isEanInvalid($ean)) { return false; } $currencyContext = ServiceContainer::getService(CurrencyContext::class); $row = sqlQueryBuilder() ->select('p.title, COALESCE(pv.ean, p.ean) as ean, COALESCE(pv.code, p.code) as code, pv.title as variation_title, p.id as id_product, pv.id as id_variation, wp.pieces, COALESCE(pv.note, p.note) as note, wpos.code as position, wp.inventory_date, p.discount, COALESCE(pv.price, p.price) as price, COALESCE(pv.in_store, p.in_store) as in_store') ->addSelect('COALESCE(pv.in_store, p.in_store) as in_store') ->addSelect(\Query\Product::withVat()) ->from('products', 'p') ->joinVariationsOnProducts() ->joinVatsOnProducts() ->join('p', 'warehouse_products', 'wp', 'p.id=wp.id_product AND (pv.id IS NULL OR pv.id=wp.id_variation)') ->join('wp', 'warehouse_positions', 'wpos', 'wp.id_position=wpos.id') ->leftJoin('wp', 'products_of_suppliers', 'pos', 'wp.id_product=pos.id_product AND (wp.id_variation IS NULL OR wp.id_variation=pos.id_variation)') ->andWhere(Operator::equals(['pv.ean' => $ean, 'p.ean' => $ean, 'pos.ean' => $ean], 'OR')) ->addSelect(\Query\Product::withProductPhotoId(true)) ->setMaxResults(1) ->orderBy('wp.pieces', 'DESC'); if (findModule(\Modules::PRODUCTS_BATCHES)) { $row->addSelect('wp.id_product_batch'); if ($batchId !== false) { $row->andWhere(Operator::equalsNullable(['wp.id_product_batch' => $batchId])); } } if ($id_location) { $row->andWhere(Operator::equals(['wpos.id_location' => $id_location])); } elseif ($code_position) { $row->andWhere(Operator::equals(['wp.id_position' => $this->getPositionIdByCode($code_position)])); } $row = $row->execute() ->fetch(); if (!empty($row['id_product'])) { $row['image'] = getImage($row['id_photo'], null, null, 4); $row['ean'] = formatEAN($row['ean']); $productPrice = new ProductPrice(toDecimal($row['price']), $currencyContext->getDefault(), getVat($row['vat']), $row['discount']); $row['priceWithVatWithDiscount'] = $productPrice->getPriceWithVat(true)->asFloat(); $row['priceWithVatWithOutDiscount'] = $productPrice->getPriceWithVatWithoutDiscount(true)->asFloat(); $row['piecesInOrder'] = $this->getPiecesInOrder($row, $code_position); $row['sellPerDay'] = round(sqlQueryBuilder() ->select('SUM(oi.pieces) / 365') ->from('orders', 'o') ->join('o', 'order_items', 'oi', 'oi.id_order=o.id') ->andWhere('o.date_handle >= NOW() - INTERVAL 365 DAY') ->andWhere('(oi.id_product=:id_product AND (oi.id_variation IS NULL OR oi.id_variation=:id_variation))') ->setParameters([ 'id_product' => $row['id_product'], 'id_variation' => $row['id_variation'], ]) ->groupBy('oi.id_product, oi.id_variation') ->execute() ->fetchColumn(), 2); $positionsQb = sqlQueryBuilder() ->select('wp.*') ->addSelect('(SELECT code FROM warehouse_positions WHERE id = wp.id_position LIMIT 1) as code') ->from('warehouse_products', 'wp') ->where(Operator::equals(['id_product' => $row['id_product']])) ->orderBy('wp.pieces', 'DESC'); if ($row['id_variation']) { $positionsQb->andWhere(Operator::equals(['wp.id_variation' => $row['id_variation']])); } else { $positionsQb->andWhere(Operator::isNull('wp.id_variation')); } $row['positions'] = $positionsQb->execute()->fetchAll(); } return $row; } /** * @param $spec callable|string Spec to filter product * * @return array of positions */ public function getProductPositions($spec) { $positions = sqlQueryBuilder() ->select('wpos.code, wp.pieces') ->from('products', 'p') ->joinVariationsOnProducts() ->leftJoin('p', 'warehouse_products', 'wp', 'p.id = wp.id_product AND pv.id <=> wp.id_variation') ->join('wp', 'warehouse_positions', 'wpos', 'wpos.id=wp.id_position') ->join('wpos', 'warehouse_locations', 'wl', 'wl.id = wpos.id_location') ->andWhere($spec); if (findModule(\Modules::PRODUCTS_BATCHES)) { $positions->addSelect('wp.id_product_batch, pb.code batch_code, pb.date_expiry') ->leftJoin('wp', 'products_batches', 'pb', 'wp.id_product_batch = pb.id'); } $positions = $this ->applyProductPositionOrdering($positions) ->execute() ->fetchAll(); return $positions; } protected function applyProductPositionOrdering(QueryBuilder $qb): QueryBuilder { $qb->orderBy('wp.pieces > 0', 'DESC'); if (findModule(\Modules::PRODUCTS_BATCHES)) { $qb->addOrderBy('pb.date_expiry IS NULL') ->addOrderBy('pb.date_expiry'); } $qb->addOrderBy('wp.over_supply = "N"', 'DESC') ->addOrderBy('wl.sort_index', 'ASC') ->addOrderBy('wp.pieces', 'ASC') ->addOrderBy('wpos.code', 'ASC'); return $qb; } public function createStoreItem(StoreItem $storeItem, int $pieces, ?int $position = null, $note = null, $birth = false) { if (!$position) { $position = $this->findTablePosition(); } $whereBatch = (findModule(\Modules::PRODUCTS_BATCHES)) ? ['id_product_batch' => $storeItem->getIdProductsBatch()] : []; if (sqlQueryBuilder() ->select('id_product') ->from('warehouse_products') ->andWhere(Operator::equalsNullable( [ 'id_product' => $storeItem->getIdProduct(), 'id_variation' => $storeItem->getIdVariation(), 'id_position' => $position, ] + $whereBatch)) ->execute() ->fetch()) { self::updateOnPosition($storeItem, $position, $pieces); } else { self::insertIntoPosition($storeItem, $position, $pieces); } if ($birth) { $this->logger->logProductMove($storeItem, $pieces, null, $position, null, $note); } } /** * @param bool $overSupply * * @return string */ public function storeItemFromTable($ean, $pieces, $positionFrom_code, $positionTo_code, $overSupply = false, $batchId = false) { $product_row = self::checkProductByEanAndLocation($ean, null, $positionFrom_code, $batchId); $product_row['over_supply'] = ($overSupply ? 'Y' : 'N'); $id_position = self::getPositionIdByCode($positionTo_code); $id_position_from = self::getPositionIdByCode($positionFrom_code); if (empty($id_position)) { return 'Zvolena pozice neexistuje'; } if (empty($id_position_from)) { return 'Zdrojová pozice neexistuje'; } if ($pieces > $product_row['pieces']) { return 'Nelze naskladnit tolik kusu!'; } sqlGetConnection()->transactional(function () use ($pieces, $product_row, $id_position, $id_position_from, &$result) { self::moveBetweenPositions(new StoreItem($product_row), $pieces, $id_position_from, $id_position); $result = true; }); return $result ? $result : 'Nedefinovaná chyba'; } /** * @throws \Exception */ public function findTablePosition() { if ($this->box_position) { return $this->box_position; } return $this->box_position = $this->findTablePositions()[0]; } /** * @return int[] * * @throws \Exception */ public function findTablePositions() { return array_values(sqlFetchAll(sqlQueryBuilder() ->select('id') ->from('warehouse_positions') ->where(Operator::equals(['id_location' => self::LOCATION_TABLE])) ->orderBy('id', 'ASC') ->execute(), ['id' => 'id'])); } /** * @return bool|string * * @throws \Exception */ public function getPositionIdByCode($code) { return sqlQueryBuilder()->select('id')->from('warehouse_positions')->where(Operator::like(['code' => $code]))->execute()->fetchColumn(); } protected function insertIntoPosition(StoreItem $storeItem, int $id_position, int $pieces) { $whereBatch = (findModule(\Modules::PRODUCTS_BATCHES)) ? ['id_product_batch' => $storeItem->getIdProductsBatch()] : []; sqlQueryBuilder()->insert('warehouse_products') ->directValues([ 'id_product' => $storeItem->getIdProduct(), 'id_variation' => $storeItem->getIdVariation(), 'pieces' => $pieces, 'id_position' => $id_position, 'over_supply' => $storeItem->isOverSupply() ? 'Y' : 'N', ] + $whereBatch)->execute(); } /** * @return \Doctrine\DBAL\Driver\Statement|int */ public function updateOnPosition(StoreItem $storeItem, int $id_position, int $pieces) { $whereBatch = (findModule(\Modules::PRODUCTS_BATCHES)) ? ['id_product_batch' => $storeItem->getIdProductsBatch()] : []; $qb = sqlQueryBuilder()->update('warehouse_products') ->set('pieces', 'pieces + :pieces') ->where(Operator::equalsNullable([ 'id_position' => $id_position, 'id_product' => $storeItem->getIdProduct(), 'id_variation' => $storeItem->getIdVariation(), ] + $whereBatch)) ->setParameter('pieces', $pieces); if ($storeItem->isOverSupply()) { $qb->set('over_supply', "'Y'"); } try { $updatedRows = $qb->execute(); } catch (DriverException $e) { if (strpos($e->getMessage(), 'Numeric value out of range')) { throw $this->preparePositionException($storeItem, $id_position, $pieces); } } if ($updatedRows == 0) { throw $this->preparePositionException($storeItem, $id_position, $pieces); } if (sqlQueryBuilder() ->select('1') ->from('warehouse_products', 'wp') ->join('wp', 'warehouse_positions', 'wpos', 'wpos.id=wp.id_position') ->join('wpos', 'warehouse_locations', 'wl', 'wl.id=wpos.id_location') ->where(Operator::equalsNullable([ 'id_product' => $storeItem->getIdProduct(), 'id_variation' => $storeItem->getIdVariation(), 'id_position' => $id_position, 'pieces' => 0, 'wl.delete_empty' => 'Y', ] + $whereBatch)) ->execute() ->fetchAll()) { $deleteRows = sqlQueryBuilder()->delete('warehouse_products')->where(Operator::equalsNullable( [ 'id_product' => $storeItem->getIdProduct(), 'id_variation' => $storeItem->getIdVariation(), 'id_position' => $id_position, 'pieces' => 0, ] + $whereBatch))->execute(); assert($deleteRows == 1, 'StoreItemWorker::updateOnPosition::deleteIf does not remove any row'); } return $updatedRows; } private function preparePositionException(StoreItem $storeItem, $id_position, $pieces) { $position = sqlQueryBuilder()->select('code')->from('warehouse_positions')->where(Operator::equals(['id' => $id_position]))->execute()->fetchColumn(); if ($storeItem->getIdVariation()) { $product = new \Variation($storeItem->getIdProduct(), $storeItem->getIdVariation()); $product->createFromDB(); } else { $product = new \Product($storeItem->getIdProduct()); $product->createFromDB(); } $msg = 'Na zdrojové pozici ('.$position.') není dostatek kusů ('.abs($pieces).')! Produkt: '.$product->title; if (findModule(\Modules::PRODUCTS_BATCHES) && $storeItem->getIdProductsBatch()) { $msg .= ' Šarže: '; $msg .= sqlQueryBuilder()->select('code')->from('products_batches', 'pb')->where(Operator::equals(['id' => $storeItem->getIdProductsBatch()]))->execute()->fetchOne(); } return new WarehouseBaseException($msg); } /** * @return QueryBuilder * * @throws \Exception */ public function getMissingProductsQuery($showNotInStore = false) { $cfg = Config::get(); $warehouseQuantity = sqlQueryBuilder()->select('wp.id_product', 'wp.id_variation', 'SUM(wp.pieces) quantity') ->from('warehouse_products', 'wp') ->groupBy('wp.id_product', 'wp.id_variation'); $ordersQuantity = sqlQueryBuilder()->select('oi.id_product', 'oi.id_variation', 'SUM(pieces) quantity') ->from('order_items', 'oi') ->leftJoin('oi', 'orders', 'o', 'oi.id_order = o.id') ->leftJoin('oi', 'warehouse_orders', 'wo', 'oi.id_order = wo.id_order') ->where(Operator::andX( Operator::inIntArray(array_merge(getStatuses('notpacked'), $cfg['Order']['Status']['warehouse_remaining'] ?? []), 'o.status'), 'wo.id_position IS NOT NULL OR wo.id IS NULL' )) ->andWhere(\Query\Order::activeOnly()) ->andWhere(Operator::not(\Query\Order::byItemType('return'))) ->groupBy('oi.id_product', 'oi.id_variation'); $qb = sqlQueryBuilder() ->select(); if ($showNotInStore) { $qb->fromProducts() ->joinVariationsOnProducts() ->leftJoin('p', 'warehouse_products', 'wp', 'p.id = wp.id_product AND (pv.id=wp.id_variation OR wp.id_variation IS NULL)') ->andWhere('wp.id_product IS NULL'); } else { $qb->from('warehouse_products', 'wp') ->leftJoin('wp', 'products', 'p', 'p.id = wp.id_product') ->joinVariationsOnProducts(); } $qb->leftJoinSubQuery('p', $warehouseQuantity, 'wq', 'wq.id_product = p.id AND (wq.id_variation = pv.id OR (wq.id_variation IS NULL AND pv.id IS NULL))') ->leftJoinSubQuery('p', $ordersQuantity, 'oq', 'oq.id_product = p.id AND (oq.id_variation = pv.id OR (oq.id_variation IS NULL AND pv.id IS NULL))') ->andwhere('COALESCE(pv.in_store, p.in_store) != COALESCE(wq.quantity, 0) - COALESCE(oq.quantity, 0)') ->groupBy('p.id, pv.id'); if (findModule(\Modules::PRODUCT_SETS, \Modules::SUB_CALCULATE_STOCK)) { // Hide sets - virtual store $isSetMaster = sqlQueryBuilder()->select('1') ->from('products_sets', 'ps') ->where('ps.id_product = p.id'); $qb->andWhere(Operator::not(Operator::exists($isSetMaster))); } return $qb; } /** * @return array * * @throws \Exception */ public function getProductsOnPositions(array $id_positions) { $products = sqlQueryBuilder() ->select('wp.id_product, wp.id_variation, p.title as p_title, pv.title as pv_title, COALESCE(pv.ean, p.ean) as ean, COALESCE(pv.code, p.code) as code, wp.pieces, wpos.code as position, wp.inventory_date, wp.over_supply, DATEDIFF(NOW(),wpos.inventory_date) days_after_inventory, GROUP_CONCAT(pos.ean SEPARATOR ";") as supplier_eans, GROUP_CONCAT(pos.code SEPARATOR ";") as supplier_codes ') ->from('warehouse_products', 'wp') ->leftJoin('wp', 'warehouse_positions', 'wpos', 'wpos.id=wp.id_position') ->leftJoin('wp', 'products', 'p', 'p.id=wp.id_product') ->leftJoin('wp', 'products_variations', 'pv', 'pv.id=wp.id_variation') ->leftJoin('wp', 'products_of_suppliers', 'pos', 'wp.id_product=pos.id_product AND (wp.id_variation=pos.id_variation OR wp.id_variation IS NULL)') ->where(Operator::inIntArray($id_positions, 'wpos.id')) ->addSelect(\Query\Product::withProductPhotoId(true)) ->orderBy('wpos.id') ->addOrderBy('wp.pieces', 'DESC') ->groupBy('wp.id_product, wp.id_variation'); if (findModule(\Modules::PRODUCTS_BATCHES)) { $products->leftJoin('wp', 'products_batches', 'pb', 'pb.id = wp.id_product_batch') ->addSelect('wp.id_product_batch, pb.code batch_code, pb.date_expiry, p.batch_number_require') ->addGroupBy('wp.id_product_batch'); } $products = $products->execute()->fetchAll(); foreach ($products as $key => &$item) { $item['image'] = getImage($item['id_photo'], null, null, 4); $item['over_supply'] = $item['over_supply'] == 'Y'; $item['ean'] = formatEAN($item['ean']); $item['suppliers_eans'] = []; foreach (explode(';', $item['supplier_eans']) as $ean) { $item['suppliers_eans'][formatEAN($ean)] = formatEAN($ean); } $codes = explode(';', $item['supplier_codes']); $item['suppliers_codes'] = array_combine($codes, $codes); } return $products; } /** * @return array * * @throws \Exception */ public function startCheckout($order_no, $box) { return sqlGetConnection()->transactional(function () use ($order_no, $box) { $checkoutValid = $this->startCheckoutValidate($order_no, $box); if ($checkoutValid !== true) { return $checkoutValid; } return $this->startCheckoutRetrieveItems($order_no, $box); }); } public function startMassCheckout($ordersBoxes) { return sqlGetConnection()->transactional(function () use ($ordersBoxes) { foreach ($ordersBoxes as $orderBox) { $checkoutValid = $this->startCheckoutValidate($orderBox->order_no, $orderBox->box); if ($checkoutValid !== true) { return $checkoutValid; } } $ordersItemsAll = []; $ordersIds = []; $ordersStateAll = []; $notesWarehouse = []; $resultAll = true; foreach ($ordersBoxes as $orderBox) { $order = $this->startCheckoutRetrieveItems($orderBox->order_no, $orderBox->box); $ordersItemsAll = array_merge($ordersItemsAll, $order['order']['items']); $ordersIds[$orderBox->order_no] = $order['order']['id']; $notesWarehouse[$orderBox->order_no] = $order['order']['note_warehouse']; $ordersStateAll = array_merge($ordersStateAll, $order['state']->checkouts); if (!$order['result']) { $resultAll = false; } } $this->sortByPositionString($ordersItemsAll, 'mainPosition'); $order = [ 'items' => $ordersItemsAll, 'note_warehouse' => $notesWarehouse, ]; return [ 'orders' => $ordersIds, 'order' => $order, 'state' => ['checkouts' => $ordersStateAll], 'result' => $resultAll, ]; }); } protected function startCheckoutValidate($order_no, $box) { try { $order = \Order::createFromDbOrderNo($order_no); } catch (\InvalidArgumentException $e) { return $this->returnError("Objednávka {$order_no} neexistuje!"); } $box_code = $box; $box = sqlQueryBuilder()->select('wpos.id, wo.id_order', 'COUNT(wp.id_product) products') ->from('warehouse_positions', 'wpos') ->leftJoin('wpos', 'warehouse_orders', 'wo', 'wpos.id = wo.id_position') ->leftJoin('wpos', 'warehouse_products', 'wp', 'wp.id_position = wpos.id') ->where(Operator::like(['wpos.code' => $box, 'wpos.id_location' => static::LOCATION_BOX])) ->groupBy('wpos.id') ->execute()->fetch(); $warehouseOrder = $this->getWarehouseOrder($order->id); if (!empty($this->startOrderCheckoutStatuses)) { if (!in_array($order->status, $this->startOrderCheckoutStatuses)) { return $this->returnError("Objednávku {$order_no} nelze vychystat, protože je v nesprávném stavu!"); } } if (!$box) { return $this->returnError("Přepravka {$box_code} neexistuje!"); } elseif (empty($warehouseOrder) && $order->status_storno) { return $this->returnError("Objednávka {$order_no} byla stornována!"); } elseif ($box['id_order'] && $box['id_order'] != $order->id) { return $this->returnError("Přepravka {$box_code} už obsahuje objednávku {$box['id_order']}!"); } elseif (!empty($warehouseOrder) && $warehouseOrder['id_position'] != $box['id']) { if ($warehouseOrder['id_position']) { return $this->returnError("Objednávka {$order_no} už je vychystávána do přepravky {$warehouseOrder['code']}!"); } else { return $this->returnError("Objednávka {$order_no} už byla vychystána {$warehouseOrder['date_start']}!"); } } elseif ($warehouseOrder === false && $box['products'] > 0) { return $this->returnError("Přepravka {$box_code} není prázdná!"); } // Assert box is empty if (!($warehouseOrder['state'] ?? false)) { assert((int) returnSQLResult('SELECT COUNT(*) FROM warehouse_products WHERE id_position=:id', ['id' => $box['id']]) === 0, 'Box for new order is empty'); } return true; } protected function startCheckoutRetrieveItems($order_no, $box) { $order = \Order::createFromDbOrderNo($order_no); $box = sqlQueryBuilder()->select('wpos.id', 'wpos.code') ->from('warehouse_positions', 'wpos') ->where(Operator::like(['wpos.code' => $box, 'wpos.id_location' => static::LOCATION_BOX])) ->execute()->fetch(); // Assign box to order sqlQuery( 'INSERT INTO warehouse_orders (id_order, id_position, date_start) VALUES (:id_order, :id_position, NOW()) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)', ['id_position' => $box['id'], 'id_order' => $order->id] ); $id_warehouse_order = sqlInsertId(); list($checkouts, $items) = $this->getCheckoutItems($id_warehouse_order, $order); // Log to order used BOX $order->logHistory("Objednávka se vychystává do přepravky: {$box['code']}", false); $order = [ 'id' => $order->id, 'id_warehouse' => $id_warehouse_order, 'order_no' => $order->order_no, 'items' => array_values($items), // Pass as array to preserve ordering 'note_warehouse' => $order->getData('note_warehouse'), ]; $state = json_decode_strict($warehouseOrder['state'] ?? '{}'); $state->checkouts = $checkouts; return [ 'order' => $order, 'state' => $state, 'result' => !empty($order), ]; } /** * @param $statuses array|null */ public function setStartOrderCheckoutStatuses($statuses) { $this->startOrderCheckoutStatuses = $statuses; } /** * @param null $date * * @throws \Throwable */ public function moveBetweenPositions(StoreItem $storeItem, int $pieces, int $old_position, int $new_position, $date = null) { sqlGetConnection()->transactional(function () use ($storeItem, $pieces, $old_position, $new_position, $date, &$result) { $this->updateOnPosition($storeItem, $old_position, -$pieces); $this->createStoreItem($storeItem, $pieces, $new_position); $this->eventDispatcher->dispatch(new WarehouseProductMoveEvent($storeItem, $pieces, $old_position, $new_position)); $result = $this->logger->logProductMove($storeItem, $pieces, $old_position, $new_position, $date); }); return $result; } /** * @throws \Exception * @throws \Throwable */ public function finish($id_warehouse_order, $state) { sqlGetConnection()->transactional(function () use ($id_warehouse_order, $state, &$return) { $result = $this->commit($id_warehouse_order, $state); if (!$result['result']) { return $result; } // Mark order finished sqlQuery('UPDATE warehouse_orders SET date_finish=NOW() WHERE id=:id', ['id' => $id_warehouse_order]); $return = [ 'result' => true, ]; }); return $return; } public function finishCompletedOrders($orders) { foreach ($orders as $orderId) { $warehouseId = sqlQueryBuilder()->select('id')->from('warehouse_orders') ->where(Operator::equals(['id_order' => $orderId])) ->execute()->fetchColumn(); $differences = $this->checkOrderMatchesBoxContent($warehouseId); if (empty($differences)) { sqlQueryBuilder()->update('warehouse_orders')->set('date_finish', 'NOW()') ->where(Operator::equals(['id_order' => $orderId]))->execute(); } } } /** * @throws \Exception * @throws \Throwable */ public function finishMass($ordersIds, $state) { sqlGetConnection()->transactional(function () use ($ordersIds, $state, &$return) { $result = $this->commitMass($state); if (!$result['result']) { return $result; } // Mark orders finished sqlQueryBuilder()->update('warehouse_orders')->set('date_finish', 'NOW()') ->where(Operator::inStringArray($ordersIds, 'id_order'))->execute(); $return = [ 'result' => true, ]; }); return $return; } /** * @return array * * @throws \Exception * @throws \Throwable */ public function commit($id_warehouse_order, &$state) { sqlGetConnection()->transactional(function () use ($id_warehouse_order, &$state, &$return) { $checkouts = &$state->checkouts; $warehouse_order = sqlFetch(sqlQuery('SELECT * from warehouse_orders where id=:id FOR UPDATE', ['id' => $id_warehouse_order])); if (!$warehouse_order) { return $return = $this->returnError('Nemohu najít rozpracovanou objednávku!'); } $commits = json_decode_strict($warehouse_order['state'] ?? '{}' ?: '{}', true)['commits'] ?? []; foreach ($checkouts as &$checkedItem) { $this->commitOrderItem($commits, $checkedItem, $warehouse_order['id_order'], $warehouse_order['id_position']); } $state->commits = $commits; // Save order status sqlQuery('UPDATE warehouse_orders SET state=:state WHERE id=:id', ['id' => $id_warehouse_order, 'state' => json_encode($state)]); $return = [ 'result' => true, ]; }); return $return; } /** * @return array * * @throws \Exception * @throws \Throwable */ public function commitMass(&$state) { sqlGetConnection()->transactional(function () use (&$state, &$return) { $checkouts = &$state->checkouts; $warehouse_orders_positions = []; $orderIds = array_unique(array_filter(array_map(function ($checkout) { return $checkout->id_order ?? false; }, $checkouts))); $commits = []; foreach ($orderIds as $orderId) { $warehouse_order = sqlFetch(sqlQuery('SELECT * from warehouse_orders where id_order=:id_order FOR UPDATE', ['id_order' => $orderId])); if (!$warehouse_order) { return $return = $this->returnError('Nemohu najít rozpracovanou objednávku!'); } // separate commits when multipicking $commits[$orderId] = json_decode_strict($warehouse_order['state'] ?? '{}' ?: '{}', true)['commits'] ?? []; $warehouse_orders_positions[$warehouse_order['id_order']] = $warehouse_order['id_position']; } foreach ($checkouts as &$checkedItem) { $orderId = $checkedItem->id_order ?? false; if ($orderId) { $this->commitOrderItem($commits[$orderId], $checkedItem, $orderId, $warehouse_orders_positions[$orderId]); } } $checkoutsOriginal = $state->checkouts; $commitsAll = []; // Save order status foreach ($commits as $orderId => $orderCommits) { $state->commits = $orderCommits; $commitsAll = $orderCommits + $commitsAll; // filter checkouts for multipicking $state->checkouts = array_filter($checkoutsOriginal, function ($checkout) use ($orderId) { return ($checkout->id_order ?? false) == $orderId; }); sqlQuery('UPDATE warehouse_orders SET state=:state WHERE id_order=:id_order', ['id_order' => $orderId, 'state' => json_encode($state)]); } $state->checkouts = $checkoutsOriginal; $state->commits = $commitsAll; $return = [ 'result' => true, ]; }); return $return; } protected function commitOrderItem(&$commits, $checkedItem, $orderId, $warehousePositionId) { if ($checkedItem->uuid ?? '') { // Skip if this uuid already committed if (array_key_exists($checkedItem->uuid, $commits)) { return; } if (strpos($checkedItem->id_item, '_')) { // V id neni id polozky, ale produkt_variata, protoze je to smazana polozka $parts = explode('_', $checkedItem->id_item); $order_item = ['id_product' => $parts[0], 'id_variation' => $parts[1] ?: null]; } else { $order_item = sqlQueryBuilder()->select('*') ->from('order_items', 'oi') ->where(Operator::equals(['oi.id' => $checkedItem->id_item])) ->execute() ->fetch(); } $item_position = $this->getPositionIdByCode($checkedItem->position); if (findModule(\Modules::PRODUCTS_BATCHES) && ($checkedItem->productBatch ?? false)) { $order_item['id_product_batch'] = $checkedItem->productBatch->id ?? null; } $date = $checkedItem->date ?? null; if ($date) { $date = new \DateTime($date); $date->setTimezone(new \DateTimeZone(date_default_timezone_get())); } $storeItem = new StoreItem($order_item); $this->logger->activateOrder($orderId, function () use (&$commits, $checkedItem, $storeItem, $item_position, $date, $warehousePositionId) { if ($checkedItem->pieces > 0) { $commits[$checkedItem->uuid] = $this->moveBetweenPositions($storeItem, $checkedItem->pieces, $item_position, $warehousePositionId, $date); } else { $commits[$checkedItem->uuid] = $this->moveBetweenPositions($storeItem, -$checkedItem->pieces, $warehousePositionId, $item_position, $date); } }); } } /** * @throws \Exception */ public function cleanCompletedBox($box_id) { sqlGetConnection()->transactional(function () use ($box_id) { // Delete products from BOX - takto sklad opouštějí produky $items = sqlQueryBuilder()->select('*') ->from('warehouse_products') ->where(Operator::equals(['id_position' => $box_id])) ->execute()->fetchAll(); foreach ($items as $item) { $this->logger->logProductMove(new StoreItem($item), $item['pieces'], $box_id, null, null); } sqlQuery('DELETE FROM warehouse_products WHERE id_position=:id', ['id' => $box_id]); // Detach order from box sqlQuery('UPDATE warehouse_orders SET id_position = NULL, date_close = NOW() WHERE id_position=:id_position', ['id_position' => $box_id]); }); } /** * @return array * * @throws \Throwable */ public function moveProduct($ean, $pieces, $positionFrom, $positionTo, $overSupply = false, $batchId = false) { $product = self::checkProductByEanAndLocation($ean, null, $positionFrom, $batchId); $positionFrom = self::getPositionIdByCode($positionFrom); $positionTo = self::getPositionIdByCode($positionTo); if (!$product) { return $this->returnError('Produkt nenalezen!'); } if (!$positionFrom) { return $this->returnError('Zdrojová pozice nenalezena!'); } if (!$positionTo) { return $this->returnError('Cílová pozice nenalezena!'); } $this->moveBetweenPositions(new StoreItem($product + ['over_supply' => $overSupply]), (int) $pieces, $positionFrom, $positionTo, null); return [ 'result' => true, ]; } /** * @throws \Exception */ public function checkOrderMatchesBoxContent($id_warehouse_order, $id_order = null, $id_box = null): array { $warehouse_order = sqlQueryBuilder()->select('*')->from('warehouse_orders')->where(Operator::equals(['id' => $id_warehouse_order]))->execute()->fetch(); if (!$warehouse_order && $id_order !== null) { $orderID = $id_order; } else { $orderID = $warehouse_order['id_order']; } $idBoxToCheck = $id_box ?: ($warehouse_order['id_position'] ?? null); $positionSubselect = sqlQueryBuilder() ->select('CONCAT(wl.sort_index, "|", wpos.code)') ->from('warehouse_locations', 'wl') ->join('wl', 'warehouse_positions', 'wpos', 'wpos.id_location = wl.id AND wpos.id_location > 2') ->join('wpos', 'warehouse_products', 'wp', 'wpos.id = wp.id_position') ->where('oi.id_product = wp.id_product AND (oi.id_variation=wp.id_variation OR wp.id_variation IS NULL)') ->setMaxResults(1); if (findModule(\Modules::PRODUCTS_BATCHES)) { $positionSubselect->leftJoin('wp', 'products_batches', 'pb', 'wp.id_product_batch = pb.id'); } $this->applyProductPositionOrdering($positionSubselect); $order_items = sqlQueryBuilder()->select('oi.id_product, oi.id_variation, SUM(oi.pieces) as pieces, oi.id as id_item, oi.id_order') ->addSubselect($positionSubselect, 'position') ->from('order_items', 'oi') ->where(Operator::equals(['id_order' => $orderID])) ->andWhere('oi.id_product IS NOT NULL') ->andWhere('oi.pieces > 0') ->groupBy('oi.id_product, oi.id_variation') ->execute()->fetchAll(); $box_items = sqlQueryBuilder()->select('wp.id_product, wp.id_variation, SUM(wp.pieces) as pieces') ->addSelect($orderID.' as id_order') ->addSelect('(SELECT CONCAT(MIN(wl.sort_index), "|", MIN(wpos.code)) FROM warehouse_locations wl JOIN warehouse_positions wpos ON wpos.id_location = wl.id WHERE wpos.id = wp.id_position) AS position') ->from('warehouse_products', 'wp') ->where(Operator::equals(['id_position' => $idBoxToCheck])) ->groupBy('wp.id_product, wp.id_variation'); if (findModule(\Modules::PRODUCTS_BATCHES)) { $box_items->addSelect('GROUP_CONCAT(wp.id_product_batch) as batches_in_box'); } $box_items = $box_items->execute()->fetchAll(); foreach ($box_items as &$box_item) { if (empty($box_item['batches_in_box'])) { continue; } $box_item['batches_in_box'] = sqlQueryBuilder()->select('pb.id id_product_batch, pb.code batch_code, pb.date_expiry, wp.pieces') ->from('warehouse_products', 'wp') ->innerJoin('wp', 'products_batches', 'pb', 'wp.id_product_batch = pb.id') ->where(Operator::equals(['wp.id_position' => $idBoxToCheck])) ->andWhere(Operator::inIntArray(explode(',', $box_item['batches_in_box']), 'pb.id')) ->execute()->fetchAllAssociative(); } $errors = $this->compareItemsVsBox($order_items, $box_items); return $errors; } public function getItemsFromOrdersBox($id_order) { return sqlQueryBuilder()->select('wp.*') ->from('warehouse_orders', 'wo') ->join('wo', 'warehouse_products', 'wp', 'wp.id_position = wo.id_position') ->where(Operator::equals(['wo.id_order' => $id_order])) ->execute()->fetchAll(); } /** * @param array $difference * * @return array */ public function generateCheckoutItems($difference) { $checkouts = []; $items = array_filter(array_map(function ($item) use (&$checkouts) { if ($item['pieces'] < 0) { return false; } $item_id = $item['id_item'] ?? "{$item['id_product']}_{$item['id_variation']}"; $variation = sqlQueryBuilder()->select('COALESCE(pv.ean, p.ean) ean', 'COALESCE(pv.code, p.code) code, pv.title title') ->fromProducts() ->joinVariationsOnProducts() ->where(Operator::equalsNullable(['p.id' => $item['id_product'], 'pv.id' => $item['id_variation']])) ->addSelect(\Query\Product::withProductPhotoId(true)) ->execute() ->fetch(); $variation['ean'] = formatEAN($variation['ean']); $image = getImage($variation['id_photo'], null, null, 4); if ($item['pieces_box'] > 0) { $checkouts[] = [ 'ean' => $variation['ean'], 'position' => '', 'id_item' => $item_id, 'id_order' => $item['id_order'] ?? null, 'pieces' => (int) $item['pieces_box'], 'productsBatches' => $item['batches_in_box'] ?? [], ]; } return [ 'id' => $item_id, 'id_product' => $item['id_product'], 'id_variation' => $item['id_variation'], 'pieces' => (int) $item['pieces'], 'title' => $item['product']->title, 'ean' => $variation['ean'], 'eans' => $variation['ean'] ? [$variation['ean']] : [], 'code' => $variation['code'], 'variation_title' => $variation['title'] ?? '', 'note' => '', 'image' => $image, 'id_order' => $item['id_order'] ?? null, 'mainPosition' => $item['position'], 'positions' => $this->getProductPositions(Operator::andX( Operator::equalsNullable(['p.id' => $item['id_product'], 'pv.id' => $item['id_variation']]), Operator::not(Operator::inIntArray([self::LOCATION_BOX], 'wpos.id_location'))) ), ]; }, $difference)); return [$checkouts, $items]; } /** * @return array */ public function compareItemsVsBox($items, $box_items) { $errors = []; // Missing in box, incorrect pieces foreach ($items as $item) { $box_found = array_filter($box_items, function ($x) use ($item) { return $x['id_product'] === $item['id_product'] && $x['id_variation'] === $item['id_variation']; }); $box_item = reset($box_found); if (!$box_item) { if ($item['pieces'] > 0) { $errors[] = array_merge($item, ['message' => "V přepravce chybí {$item['pieces']}x produkt {PRODUCT}", 'pieces_box' => '0']); } continue; } if ($box_item['pieces'] != $item['pieces']) { $errors[] = array_merge($item, [ 'message' => "V přepravce je špatný počet kusů ({$box_item['pieces']} přepravka != {$item['pieces']} objednávka) produktu {PRODUCT}", 'pieces_box' => $box_item['pieces'], 'batches_in_box' => $box_item['batches_in_box'], ]); } unset($box_items[key($box_found)]); } // Missing in order (remaining in box) foreach ($box_items as $box_item) { if ($box_item['pieces'] > 0) { $errors[] = array_merge($box_item, ['message' => "V přepravce přebývá {$box_item['pieces']}x produkt {PRODUCT}", 'pieces_box' => $box_item['pieces'], 'pieces' => '0']); } } foreach ($errors as $key => &$error) { /** @var \Product $product */ $product = new \Product(); $product->createFromDB($error['id_product']); if ($product->isVirtual()) { unset($errors[$key]); continue; } $error['product'] = $product; $productName = sqlQueryBuilder()->select('CONCAT_WS(" ", p.title, pv.title)') ->fromProducts()->joinVariationsOnProducts() ->where(Operator::equalsNullable(['p.id' => $error['id_product'], 'pv.id' => $error['id_variation']])) ->execute()->fetchColumn(); $error['message'] = replacePlaceholders($error['message'], ['PRODUCT' => $productName]); } $this->sortByPositionString($errors, 'position'); return $errors; } public function getWarehouseOrder($id_order) { $warehouseOrder = sqlQueryBuilder()->select('wo.*, wp.code') ->from('warehouse_orders', 'wo') ->leftJoin('wo', 'warehouse_positions', 'wp', 'wp.id = wo.id_position') ->where(Operator::equals(['id_order' => $id_order])) ->execute()->fetch(); return $warehouseOrder; } /** * @param string $id_warehouse_order * @param \Order $order * * @return array * * @throws \Exception */ public function getCheckoutItems($id_warehouse_order, &$order) { // Get box vs order difference $difference = $this->checkOrderMatchesBoxContent($id_warehouse_order); // Get unfinished order items and generate checkouts list($checkouts, $items) = $this->generateCheckoutItems($difference); // Get EANs from suppliers $suppliers_eans = sqlQueryBuilder()->select('oi.id id_item, pos.ean, pos.id_product, pos.id_variation') ->from('order_items', 'oi') ->join('oi', 'products_of_suppliers', 'pos', 'pos.id_product = oi.id_product AND '.Operator::equalsToOrNullable('pos.id_variation', 'oi.id_variation')) ->where(Operator::inIntArray(array_keys($order->fetchItems()), 'oi.id')) ->execute()->fetchAll(); foreach ($items as &$item) { foreach ($suppliers_eans as $row) { if ($item['id'] == $row['id_item'] || $item['id'] == "{$row['id_product']}_{$row['id_variation']}") { $item['eans'][] = formatEAN($row['ean']); } } } return [$checkouts, $items]; } /** * @return array * * @throws \Exception */ public function loadOrderStoreItems($id_warehouse_order, $id_order = null) { $warehouse_order = sqlQueryBuilder()->select('id, id_order') ->from('warehouse_orders'); if ($id_order) { $warehouse_order->where(Operator::equals(['id_order' => $id_order])); } else { $warehouse_order->where(Operator::equals(['id' => $id_warehouse_order])); } $warehouse_order = $warehouse_order->execute()->fetch(); $order = new \Order($warehouse_order['id_order']); return $this->getCheckoutItems($warehouse_order['id'], $order); } /** * @return array * * @throws \Exception */ public function loadOrderStoreMassItems($ordersId) { $checkoutsAll = []; $itemsAll = []; foreach ($ordersId as $orderId) { list($checkouts, $items) = $this->loadOrderStoreItems(null, $orderId); $checkoutsAll = array_merge($checkoutsAll, $checkouts); $itemsAll = array_merge($itemsAll, $items); } $this->sortByPositionString($itemsAll, 'mainPosition'); return [$checkoutsAll, $itemsAll]; } protected function sortByPositionString(&$items, $field) { usort($items, function ($x, $y) use ($field) { return explode('|', $x[$field]) <=> explode('|', $y[$field]); }); } protected function getPiecesInOrder($row, $code_position) { $event = new WarehouseProductEvent(); $row['table_position'] = $code_position; $event->setStoreInItemRow($row); $this->eventDispatcher->dispatch($event, WarehouseProductEvent::PIECES_IN_ORDERS_SPEC); return sqlQueryBuilder() ->select('SUM(oi.pieces) as piecesInOrder') ->from('orders', 'o') ->leftJoin('o', 'order_items', 'oi', 'oi.id_order = o.id') ->setParameters( [ 'id_product' => $row['id_product'], 'id_variation' => $row['id_variation'], ] ) ->andWhere( 'oi.id_product=:id_product AND (oi.id_variation IS NULL OR oi.id_variation=:id_variation)' ) ->andWhere(Operator::andX($event->getPiecesInOrdersSpec())) ->andWhere(\Query\Order::notPackedOrders()) ->groupBy('oi.id_product, oi.id_variation') ->execute() ->fetchColumn(); } /** * @required */ public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void { $this->eventDispatcher = $eventDispatcher; } public function getLocationByPosition($position) { return sqlQueryBuilder()->select('wp.id_location') ->from('warehouse_positions', 'wp') ->where(Operator::equals(['wp.id' => $position])) ->execute()->fetchOne(); } }