db = $db; } /** * Pobiera stan magazynowy produktu * * @param int $productId ID produktu * @return int|null Ilość produktu lub null jeśli nie znaleziono */ public function getQuantity(int $productId): ?int { $quantity = $this->db->get('pp_shop_products', 'quantity', ['id' => $productId]); // Medoo zwraca false jeśli nie znaleziono return $quantity !== false ? (int)$quantity : null; } /** * Pobiera produkt po ID * * @param int $productId ID produktu * @return array|null Dane produktu lub null */ public function find(int $productId): ?array { $product = $this->db->get('pp_shop_products', '*', ['id' => $productId]); return $product ?: null; } /** * Zwraca liste produktow z archiwum do panelu admin. * * @return array{items: array>, total: int} */ public function listArchivedForAdmin( array $filters, string $sortColumn = 'id', string $sortDir = 'DESC', int $page = 1, int $perPage = 10 ): array { $allowedSortColumns = [ 'id' => 'psp.id', 'name' => 'name', 'price_brutto' => 'psp.price_brutto', 'price_brutto_promo' => 'psp.price_brutto_promo', 'quantity' => 'psp.quantity', 'combinations' => 'combinations', ]; $sortSql = $allowedSortColumns[$sortColumn] ?? 'psp.id'; $sortDir = strtoupper(trim($sortDir)) === 'ASC' ? 'ASC' : 'DESC'; $page = max(1, $page); $perPage = min(self::MAX_PER_PAGE, max(1, $perPage)); $offset = ($page - 1) * $perPage; $where = ['psp.archive = 1', 'psp.parent_id IS NULL']; $params = []; $phrase = trim((string)($filters['phrase'] ?? '')); if (strlen($phrase) > 255) { $phrase = substr($phrase, 0, 255); } if ($phrase !== '') { $where[] = '( psp.ean LIKE :phrase OR psp.sku LIKE :phrase OR EXISTS ( SELECT 1 FROM pp_shop_products_langs AS pspl2 WHERE pspl2.product_id = psp.id AND pspl2.name LIKE :phrase ) )'; $params[':phrase'] = '%' . $phrase . '%'; } $whereSql = implode(' AND ', $where); $sqlCount = " SELECT COUNT(0) FROM pp_shop_products AS psp WHERE {$whereSql} "; $stmtCount = $this->db->query($sqlCount, $params); $countRows = $stmtCount ? $stmtCount->fetchAll() : []; $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0; $sql = " SELECT psp.id, psp.price_brutto, psp.price_brutto_promo, psp.quantity, psp.sku, psp.ean, ( SELECT pspl.name FROM pp_shop_products_langs AS pspl INNER JOIN pp_langs AS pl ON pl.id = pspl.lang_id WHERE pspl.product_id = psp.id AND pspl.name <> '' ORDER BY pl.o ASC LIMIT 1 ) AS name, ( SELECT pspi.src FROM pp_shop_products_images AS pspi WHERE pspi.product_id = psp.id ORDER BY pspi.o ASC, pspi.id ASC LIMIT 1 ) AS image_src, ( SELECT pspi.alt FROM pp_shop_products_images AS pspi WHERE pspi.product_id = psp.id ORDER BY pspi.o ASC, pspi.id ASC LIMIT 1 ) AS image_alt, ( SELECT COUNT(0) FROM pp_shop_products AS pspc WHERE pspc.parent_id = psp.id ) AS combinations FROM pp_shop_products AS psp WHERE {$whereSql} ORDER BY {$sortSql} {$sortDir}, psp.id {$sortDir} LIMIT {$perPage} OFFSET {$offset} "; $stmt = $this->db->query($sql, $params); $items = $stmt ? $stmt->fetchAll() : []; return [ 'items' => is_array($items) ? $items : [], 'total' => $total, ]; } /** * Pobiera cenę produktu (promocyjną jeśli jest niższa, w przeciwnym razie regularną) * * @param int $productId ID produktu * @return float|null Cena brutto lub null jeśli nie znaleziono */ public function getPrice(int $productId): ?float { $prices = $this->db->get('pp_shop_products', ['price_brutto', 'price_brutto_promo'], ['id' => $productId]); if (!$prices) { return null; } if ($prices['price_brutto_promo'] != '' && $prices['price_brutto_promo'] < $prices['price_brutto']) { return (float)$prices['price_brutto_promo']; } return (float)$prices['price_brutto']; } /** * Pobiera nazwę produktu w danym języku * * @param int $productId ID produktu * @param string $langId ID języka * @return string|null Nazwa produktu lub null jeśli nie znaleziono */ public function getName(int $productId, string $langId): ?string { $name = $this->db->get('pp_shop_products_langs', 'name', ['AND' => ['product_id' => $productId, 'lang_id' => $langId]]); return $name ?: null; } /** * Aktualizuje ilość produktu * * @param int $productId ID produktu * @param int $quantity Nowa ilość * @return bool Czy aktualizacja się powiodła */ public function updateQuantity(int $productId, int $quantity): bool { $result = $this->db->update( 'pp_shop_products', ['quantity' => $quantity], ['id' => $productId] ); return $result !== false; } /** * Przywraca produkt z archiwum (wraz z kombinacjami) * * @param int $productId ID produktu * @return bool Czy operacja się powiodła */ public function unarchive(int $productId): bool { $this->db->update( 'pp_shop_products', [ 'status' => 1, 'archive' => 0 ], [ 'id' => $productId ] ); $this->db->update( 'pp_shop_products', [ 'status' => 1, 'archive' => 0 ], [ 'parent_id' => $productId ] ); return true; } /** * Przenosi produkt do archiwum (wraz z kombinacjami) * * @param int $productId ID produktu * @return bool Czy operacja się powiodła */ public function archive(int $productId): bool { $this->db->update( 'pp_shop_products', [ 'status' => 0, 'archive' => 1 ], [ 'id' => $productId ] ); $this->db->update( 'pp_shop_products', [ 'status' => 0, 'archive' => 1 ], [ 'parent_id' => $productId ] ); return true; } /** * Pobiera listę wszystkich produktów głównych (id => name) do masowej edycji. * Zwraca tylko produkty bez parent_id (bez kombinacji). * * @return array Mapa id => nazwa produktu */ public function allProductsForMassEdit(): array { $defaultLang = $this->db->get( 'pp_langs', 'id', [ 'start' => 1 ] ); if ( !$defaultLang ) { $defaultLang = 'pl'; } $results = $this->db->select( 'pp_shop_products', 'id', [ 'parent_id' => null ] ); $products = []; if ( is_array( $results ) ) { foreach ( $results as $id ) { $name = $this->db->get( 'pp_shop_products_langs', 'name', [ 'AND' => [ 'product_id' => $id, 'lang_id' => $defaultLang ] ] ); $products[ (int) $id ] = $name ?: ''; } } return $products; } /** * Pobiera listę ID produktów przypisanych do danej kategorii. * * @param int $categoryId ID kategorii * @return int[] Lista ID produktów */ public function getProductsByCategory(int $categoryId): array { $results = $this->db->select( 'pp_shop_products_categories', 'product_id', [ 'category_id' => $categoryId ] ); return is_array( $results ) ? $results : []; } /** * Aplikuje rabat procentowy na produkt (cena promocyjna = cena - X%). * Aktualizuje również ceny kombinacji produktu. * * @param int $productId ID produktu * @param float $discountPercent Procent rabatu * @return array|null Tablica z price_brutto i price_brutto_promo lub null przy błędzie */ public function applyDiscountPercent(int $productId, float $discountPercent): ?array { $product = $this->db->get( 'pp_shop_products', [ 'vat', 'price_brutto', 'price_netto' ], [ 'id' => $productId ] ); if ( !$product ) { return null; } $vat = $product['vat']; $priceBrutto = (float) $product['price_brutto']; $priceNetto = (float) $product['price_netto']; $priceBruttoPromo = $priceBrutto - ( $priceBrutto * ( $discountPercent / 100 ) ); $priceNettoPromo = $priceNetto - ( $priceNetto * ( $discountPercent / 100 ) ); if ( $priceBrutto == $priceBruttoPromo ) { $priceBruttoPromo = null; } if ( $priceNetto == $priceNettoPromo ) { $priceNettoPromo = null; } $this->db->update( 'pp_shop_products', [ 'price_brutto_promo' => $priceBruttoPromo, 'price_netto_promo' => $priceNettoPromo ], [ 'id' => $productId ] ); $this->updateCombinationPrices( $productId, $priceNetto, $vat, $priceNettoPromo ); return [ 'price_brutto' => $priceBrutto, 'price_brutto_promo' => $priceBruttoPromo ]; } // ─── Krok 1: metody CRUD admin ─────────────────────────────────── /** * Liczba produktów (nie-archiwum, parent_id IS NULL). */ public function countProducts(?array $where = null): int { if ( $where ) { return (int) $this->db->count( 'pp_shop_products', $where ); } return (int) $this->db->count( 'pp_shop_products', [ 'archive' => 0 ] ); } /** * Lista produktów do panelu admin (paginacja, sortowanie, filtrowanie). * * @return array{items: array, total: int} */ public function listForAdmin( array $filters = [], string $sortColumn = 'id', string $sortDir = 'DESC', int $page = 1, int $perPage = 15 ): array { $page = max( 1, $page ); $perPage = min( max( 1, $perPage ), self::MAX_PER_PAGE ); $offset = ( $page - 1 ) * $perPage; $allowedSort = [ 'id', 'name', 'price_brutto', 'status', 'promoted', 'quantity' ]; if ( !in_array( $sortColumn, $allowedSort, true ) ) { $sortColumn = 'id'; } $sortDir = strtoupper( $sortDir ) === 'ASC' ? 'ASC' : 'DESC'; $conditions = [ 'psp.archive = 0', 'psp.parent_id IS NULL' ]; $params = []; $search = trim( (string) ( $filters['search'] ?? '' ) ); if ( $search !== '' ) { $conditions[] = '( pspl.name LIKE :search OR psp.ean LIKE :search_ean OR psp.sku LIKE :search_sku )'; $params[':search'] = '%' . $search . '%'; $params[':search_ean'] = '%' . $search . '%'; $params[':search_sku'] = '%' . $search . '%'; } $statusFilter = (string) ( $filters['status'] ?? '' ); if ( $statusFilter === '1' || $statusFilter === '0' ) { $conditions[] = 'psp.status = :status'; $params[':status'] = (int) $statusFilter; } $promotedFilter = (string) ( $filters['promoted'] ?? '' ); if ( $promotedFilter === '1' || $promotedFilter === '0' ) { $conditions[] = 'psp.promoted = :promoted'; $params[':promoted'] = (int) $promotedFilter; } $whereSql = implode( ' AND ', $conditions ); $needsJoin = $search !== '' || $sortColumn === 'name'; $joinSql = $needsJoin ? 'INNER JOIN pp_shop_products_langs AS pspl ON pspl.product_id = psp.id AND pspl.lang_id = ' . $this->db->quote( $this->defaultLangId() ) : ''; $orderColumn = $sortColumn === 'name' ? 'pspl.name' : 'psp.' . $sortColumn; $countStmt = $this->db->query( 'SELECT COUNT( DISTINCT psp.id ) AS total ' . 'FROM pp_shop_products AS psp ' . $joinSql . ' ' . 'WHERE ' . $whereSql, $params ); $countRow = $countStmt ? $countStmt->fetchAll( \PDO::FETCH_ASSOC ) : []; $total = isset( $countRow[0]['total'] ) ? (int) $countRow[0]['total'] : 0; $stmt = $this->db->query( 'SELECT DISTINCT psp.id ' . 'FROM pp_shop_products AS psp ' . $joinSql . ' ' . 'WHERE ' . $whereSql . ' ORDER BY ' . $orderColumn . ' ' . $sortDir . ', psp.id DESC' . ' LIMIT ' . $offset . ', ' . $perPage, $params ); $rows = $stmt ? $stmt->fetchAll( \PDO::FETCH_ASSOC ) : []; $items = []; foreach ( $rows as $row ) { $id = (int) ( $row['id'] ?? 0 ); if ( $id > 0 ) { $product = $this->findForAdmin( $id ); if ( $product ) { $items[] = $product; } } } return [ 'items' => $items, 'total' => $total, ]; } /** * Szczegóły produktu (admin) — zastępuje factory product_details(). */ public function findForAdmin(int $productId): ?array { $product = $this->db->get( 'pp_shop_products', '*', [ 'id' => $productId ] ); if ( !$product ) { return null; } $results = $this->db->select( 'pp_shop_products_langs', '*', [ 'product_id' => $productId ] ); if ( is_array( $results ) ) { foreach ( $results as $row ) { $product['languages'][ $row['lang_id'] ] = $row; } } $product['images'] = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId, 'ORDER' => [ 'o' => 'ASC', 'id' => 'ASC' ] ] ); $product['files'] = $this->db->select( 'pp_shop_products_files', '*', [ 'product_id' => $productId ] ); $product['categories'] = $this->db->select( 'pp_shop_products_categories', 'category_id', [ 'product_id' => $productId ] ); $product['attributes'] = $this->db->select( 'pp_shop_products_attributes', [ 'attribute_id', 'value_id' ], [ 'product_id' => $productId ] ); $product['products_related'] = $this->db->select( 'pp_shop_products_related', 'product_related_id', [ 'product_id' => $productId ] ); $product['custom_fields'] = $this->db->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] ); return $product; } /** * Prosta lista produktów (id => name) — do selectów w formularzu. */ public function allProductsList(): array { $results = $this->db->select( 'pp_shop_products', 'id', [ 'parent_id' => null ] ); $products = []; if ( is_array( $results ) ) { foreach ( $results as $id ) { $products[ $id ] = $this->db->get( 'pp_shop_products_langs', 'name', [ 'AND' => [ 'product_id' => $id, 'lang_id' => 'pl' ] ] ); } } return $products; } /** * Tekst z nazwami kategorii produktu (oddzielone " / "). */ public function productCategoriesText(int $productId): string { $results = $this->db->query( 'SELECT category_id FROM pp_shop_products_categories WHERE product_id = :pid', [ ':pid' => $productId ] ); $rows = $results ? $results->fetchAll( \PDO::FETCH_ASSOC ) : []; if ( !is_array( $rows ) || empty( $rows ) ) { return ''; } $out = ' - '; foreach ( $rows as $i => $row ) { $title = $this->db->get( 'pp_shop_categories_langs', 'title', [ 'AND' => [ 'category_id' => (int) $row['category_id'], 'lang_id' => $this->defaultLangId(), ] ] ); $out .= $title ?: ''; if ( $i < count( $rows ) - 1 ) { $out .= ' / '; } } return $out; } /** * ID produktu nadrzędnego (parent_id). */ public function getParentId(int $productId): ?int { $parentId = $this->db->get( 'pp_shop_products', 'parent_id', [ 'id' => $productId ] ); return $parentId ? (int) $parentId : null; } /** * Domyślna nazwa produktu (w domyślnym języku). */ public function productDefaultName(int $productId): ?string { $name = $this->db->get( 'pp_shop_products_langs', 'name', [ 'AND' => [ 'product_id' => $productId, 'lang_id' => $this->defaultLangId() ] ] ); return $name ?: null; } /** * ID domyślnego języka. */ private function defaultLangId(): string { $langId = $this->db->get( 'pp_langs', 'id', [ 'start' => 1 ] ); return $langId ?: 'pl'; } // ─── Krok 2: zapis produktu ────────────────────────────────────── /** * Zapis produktu (insert lub update). Zastępuje factory save(). * * @param array $d Dane produktu (z formularza) * @param int|null $userId ID aktualnego użytkownika admin * @return int|null ID produktu lub null przy błędzie */ public function saveProduct(array $d, ?int $userId = null): ?int { $productId = (int) ( $d['id'] ?? 0 ); $isNew = !$productId; $productData = [ 'date_modify' => date( 'Y-m-d H:i:s' ), 'modify_by' => $userId, 'status' => ( $d['status'] ?? '' ) === 'on' ? 1 : 0, 'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ), 'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ), 'vat' => $d['vat'] ?? 0, 'promoted' => ( $d['promoted'] ?? '' ) === 'on' ? 1 : 0, 'layout_id' => $this->nullIfEmpty( $d['layout_id'] ?? null ), 'price_netto_promo' => $this->nullIfEmpty( $d['price_netto_promo'] ?? null ), 'price_brutto_promo' => $this->nullIfEmpty( $d['price_brutto_promo'] ?? null ), 'new_to_date' => $this->nullIfEmpty( $d['new_to_date'] ?? null ), 'stock_0_buy' => ( $d['stock_0_buy'] ?? '' ) === 'on' ? 1 : 0, 'wp' => $this->nullIfEmpty( $d['wp'] ?? null ), 'sku' => $this->nullIfEmpty( $d['sku'] ?? null ), 'ean' => $this->nullIfEmpty( $d['ean'] ?? null ), 'custom_label_0' => $this->nullIfEmpty( $d['custom_label_0'] ?? null ), 'custom_label_1' => $this->nullIfEmpty( $d['custom_label_1'] ?? null ), 'custom_label_2' => $this->nullIfEmpty( $d['custom_label_2'] ?? null ), 'custom_label_3' => $this->nullIfEmpty( $d['custom_label_3'] ?? null ), 'custom_label_4' => $this->nullIfEmpty( $d['custom_label_4'] ?? null ), 'additional_message' => ( $d['additional_message'] ?? '' ) == 'on' ? 1 : 0, 'set_id' => !empty( $d['set'] ) ? (int) $d['set'] : null, 'quantity' => (int) ( $d['quantity'] ?? 0 ), 'additional_message_text' => $this->nullIfEmpty( $d['additional_message_text'] ?? null ), 'additional_message_required' => ( $d['additional_message_required'] ?? '' ) == 'on' ? 1 : 0, 'producer_id' => !empty( $d['producer_id'] ) ? $d['producer_id'] : null, 'product_unit_id' => !empty( $d['product_unit'] ) ? $d['product_unit'] : null, 'weight' => !empty( $d['weight'] ) ? $d['weight'] : null, ]; if ( $isNew ) { $productData['date_add'] = date( 'Y-m-d H:i:s' ); $this->db->insert( 'pp_shop_products', $productData ); $productId = (int) $this->db->id(); if ( !$productId ) { return null; } } else { $this->db->update( 'pp_shop_products', $productData, [ 'id' => $productId ] ); $this->db->update( 'pp_shop_products', [ 'additional_message' => $productData['additional_message'], ], [ 'parent_id' => $productId ] ); $this->updateCombinationPricesFromBase( $productId, $d['price_brutto'] ?? 0, $d['vat'] ?? 0, $d['price_brutto_promo'] ?? null ); } $this->saveLanguages( $productId, $d, $isNew ); $this->saveCategories( $productId, $d['categories'] ?? null ); $this->saveRelatedProducts( $productId, $d['products_related'] ?? null ); $this->moveTemporaryFiles( $productId ); $this->moveTemporaryImages( $productId ); if ( !empty( $d['gallery_order'] ) ) { $this->saveImagesOrder( $productId, $d['gallery_order'] ); } $this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] ); if ( !$isNew ) { $this->cleanupDeletedFiles( $productId ); $this->cleanupDeletedImages( $productId ); } \Shared\Helpers\Helpers::htacces(); \Shared\Helpers\Helpers::delete_dir( '../temp/' ); \Shared\Helpers\Helpers::delete_dir( '../thumbs/' ); if ( !$isNew ) { $redis = \Shared\Cache\RedisConnection::getInstance()->getConnection(); if ( $redis ) { $redis->flushAll(); } } return $productId; } private function saveLanguages(int $productId, array $d, bool $isNew): void { $langs = ( new \Domain\Languages\LanguagesRepository( $this->db ) )->languagesList( true ); foreach ( $langs as $lg ) { $lid = $lg['id']; $langData = [ 'name' => $this->nullIfEmpty( $d['name'][$lid] ?? null ), 'short_description' => $this->nullIfEmpty( $d['short_description'][$lid] ?? null ), 'description' => $this->nullIfEmpty( $d['description'][$lid] ?? null ), 'meta_description' => $this->nullIfEmpty( $d['meta_description'][$lid] ?? null ), 'meta_keywords' => $this->nullIfEmpty( $d['meta_keywords'][$lid] ?? null ), 'seo_link' => !empty( $d['seo_link'][$lid] ) ? \Shared\Helpers\Helpers::seo( $d['seo_link'][$lid] ) : null, 'copy_from' => $this->nullIfEmpty( $d['copy_from'][$lid] ?? null ), 'warehouse_message_zero' => $this->nullIfEmpty( $d['warehouse_message_zero'][$lid] ?? null ), 'warehouse_message_nonzero' => $this->nullIfEmpty( $d['warehouse_message_nonzero'][$lid] ?? null ), 'tab_name_1' => $this->nullIfEmpty( $d['tab_name_1'][$lid] ?? null ), 'tab_description_1' => $this->nullIfEmpty( $d['tab_description_1'][$lid] ?? null ), 'tab_name_2' => $this->nullIfEmpty( $d['tab_name_2'][$lid] ?? null ), 'tab_description_2' => $this->nullIfEmpty( $d['tab_description_2'][$lid] ?? null ), 'canonical' => $this->nullIfEmpty( $d['canonical'][$lid] ?? null ), 'meta_title' => $this->nullIfEmpty( $d['meta_title'][$lid] ?? null ), 'security_information' => $this->nullIfEmpty( $d['security_information'][$lid] ?? null ), ]; if ( $isNew ) { $langData['product_id'] = $productId; $langData['lang_id'] = $lid; $this->db->insert( 'pp_shop_products_langs', $langData ); } else { $translationId = $this->db->get( 'pp_shop_products_langs', 'id', [ 'AND' => [ 'product_id' => $productId, 'lang_id' => $lid ] ] ); if ( $translationId ) { $currentSeoLink = $this->db->get( 'pp_shop_products_langs', 'seo_link', [ 'id' => $translationId ] ); $newSeoLink = $langData['seo_link'] ?: \Shared\Helpers\Helpers::seo( 'p-' . $productId . '-' . ( $d['name'][$lid] ?? '' ) ); if ( $newSeoLink !== $currentSeoLink && $currentSeoLink != '' ) { $this->handleSeoRedirects( $productId, $lid, $newSeoLink, $currentSeoLink ); } $this->db->update( 'pp_shop_products_langs', $langData, [ 'id' => $translationId ] ); } else { $langData['product_id'] = $productId; $langData['lang_id'] = $lid; $this->db->insert( 'pp_shop_products_langs', $langData ); } } } } private function handleSeoRedirects(int $productId, string $langId, string $newSeoLink, string $currentSeoLink): void { if ( $this->db->count( 'pp_redirects', [ 'from' => $newSeoLink, 'to' => $currentSeoLink, 'lang_id' => $langId, 'product_id' => $productId ] ) ) { $this->db->delete( 'pp_redirects', [ 'from' => $newSeoLink, 'to' => $currentSeoLink, 'lang_id' => $langId, 'product_id' => $productId ] ); } $this->db->delete( 'pp_redirects', [ 'AND' => [ 'product_id' => $productId, 'lang_id' => $langId, 'from' => $currentSeoLink, 'to[!]' => $newSeoLink, ], ] ); $seoUsed = (bool) $this->db->count( 'pp_shop_products_langs', [ 'AND' => [ 'lang_id' => $langId, 'seo_link' => $currentSeoLink, 'product_id[!]' => $productId, ], ] ); if ( !$seoUsed ) { $this->db->delete( 'pp_redirects', [ 'AND' => [ 'from' => $currentSeoLink, 'lang_id' => $langId, 'product_id[!]' => $productId, ], ] ); if ( !$this->db->count( 'pp_redirects', [ 'from' => $currentSeoLink, 'to' => $newSeoLink, 'lang_id' => $langId, 'product_id' => $productId ] ) ) { if ( \Shared\Helpers\Helpers::canAddRedirect( $currentSeoLink, $newSeoLink, $langId ) ) { $this->db->insert( 'pp_redirects', [ 'from' => $currentSeoLink, 'to' => $newSeoLink, 'lang_id' => $langId, 'product_id' => $productId ] ); } } } else { $this->db->delete( 'pp_redirects', [ 'AND' => [ 'product_id' => $productId, 'lang_id' => $langId, 'from' => $currentSeoLink ] ] ); } } private function saveCategories(int $productId, $categories): void { if ( !is_array( $categories ) ) { $categories = $categories ? [ $categories ] : []; } $notIn = array_merge( [0], $categories ); $this->db->delete( 'pp_shop_products_categories', [ 'AND' => [ 'product_id' => $productId, 'category_id[!]' => $notIn ] ] ); $existing = $this->db->select( 'pp_shop_products_categories', 'category_id', [ 'product_id' => $productId ] ); $existing = is_array( $existing ) ? $existing : []; $toAdd = array_diff( $categories, $existing ); foreach ( $toAdd as $categoryId ) { if ( $productId && $categoryId ) { $order = (int) $this->db->max( 'pp_shop_products_categories', 'o' ) + 1; $this->db->insert( 'pp_shop_products_categories', [ 'product_id' => $productId, 'category_id' => (int) $categoryId, 'o' => $order, ] ); } } } private function saveRelatedProducts(int $productId, $products): void { if ( !is_array( $products ) ) { $products = $products ? [ $products ] : []; } $notIn = array_merge( [0], $products ); $this->db->delete( 'pp_shop_products_related', [ 'AND' => [ 'product_id' => $productId, 'product_related_id[!]' => $notIn ] ] ); $existing = $this->db->select( 'pp_shop_products_related', 'product_related_id', [ 'product_id' => $productId ] ); $existing = is_array( $existing ) ? $existing : []; $toAdd = array_diff( $products, $existing ); foreach ( $toAdd as $relatedId ) { if ( $productId && $relatedId ) { $this->db->insert( 'pp_shop_products_related', [ 'product_id' => $productId, 'product_related_id' => (int) $relatedId, ] ); } } } private function moveTemporaryFiles(int $productId): void { $results = $this->db->select( 'pp_shop_products_files', '*', [ 'product_id' => null ] ); if ( !is_array( $results ) ) return; $created = false; foreach ( $results as $row ) { $dir = '/upload/product_files/product_' . $productId; $newName = str_replace( '/upload/product_files/tmp', $dir, $row['src'] ); if ( file_exists( '..' . $row['src'] ) ) { if ( !is_dir( '../' . $dir ) && !$created ) { if ( mkdir( '../' . $dir, 0755, true ) ) $created = true; } rename( '..' . $row['src'], '..' . $newName ); } $this->db->update( 'pp_shop_products_files', [ 'src' => $newName, 'product_id' => $productId ], [ 'id' => $row['id'] ] ); } } private function moveTemporaryImages(int $productId): void { $results = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => null ] ); if ( !is_array( $results ) ) return; $created = false; foreach ( $results as $row ) { $dir = '/upload/product_images/product_' . $productId; $newName = str_replace( '/upload/product_images/tmp', $dir, $row['src'] ); if ( file_exists( '..' . $newName ) ) { $ext = strrpos( $newName, '.' ); $base = substr( $newName, 0, $ext ); $extension = substr( $newName, $ext ); $count = 1; while ( file_exists( '..' . $base . '_' . $count . $extension ) ) { ++$count; } $newName = $base . '_' . $count . $extension; } if ( file_exists( '..' . $row['src'] ) ) { if ( !is_dir( '../' . $dir ) && !$created ) { if ( mkdir( '../' . $dir, 0755, true ) ) $created = true; } rename( '..' . $row['src'], '..' . $newName ); } $this->db->update( 'pp_shop_products_images', [ 'src' => $newName, 'product_id' => $productId ], [ 'id' => $row['id'] ] ); } } private function saveCustomFields(int $productId, array $names, array $types, array $required): void { $existingIds = []; foreach ( $names as $i => $name ) { if ( !empty( $name ) ) { $id = (int) $this->db->get( 'pp_shop_products_custom_fields', 'id_additional_field', [ 'AND' => [ 'id_product' => $productId, 'name' => $name ] ] ); if ( $id ) $existingIds[] = $id; } } if ( !empty( $existingIds ) ) { $this->db->delete( 'pp_shop_products_custom_fields', [ 'AND' => [ 'id_product' => $productId, 'id_additional_field[!]' => $existingIds ] ] ); } else { $this->db->delete( 'pp_shop_products_custom_fields', [ 'id_product' => $productId ] ); } foreach ( $names as $i => $name ) { if ( empty( $name ) ) continue; $typeVal = $types[$i] ?? 'text'; $isRequired = !empty( $required[$i] ) ? 1 : 0; if ( !$this->db->count( 'pp_shop_products_custom_fields', [ 'AND' => [ 'id_product' => $productId, 'name' => $name ] ] ) ) { $this->db->insert( 'pp_shop_products_custom_fields', [ 'id_product' => $productId, 'name' => $name, 'type' => $typeVal, 'is_required' => $isRequired, ] ); } else { $this->db->update( 'pp_shop_products_custom_fields', [ 'type' => $typeVal, 'is_required' => $isRequired, ], [ 'AND' => [ 'id_product' => $productId, 'name' => $name ] ] ); } } } private function cleanupDeletedFiles(int $productId): void { $results = $this->db->select( 'pp_shop_products_files', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] ); if ( is_array( $results ) ) { foreach ( $results as $row ) { if ( file_exists( '../' . $row['src'] ) ) { unlink( '../' . $row['src'] ); } } } $this->db->delete( 'pp_shop_products_files', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] ); } private function cleanupDeletedImages(int $productId): void { $results = $this->db->select( 'pp_shop_products_images', '*', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] ); if ( is_array( $results ) ) { foreach ( $results as $row ) { if ( file_exists( '../' . $row['src'] ) ) { unlink( '../' . $row['src'] ); } } } $this->db->delete( 'pp_shop_products_images', [ 'AND' => [ 'product_id' => $productId, 'to_delete' => 1 ] ] ); } private function nullIfEmpty($value) { return ( $value !== null && $value !== '' && $value !== 0.00 ) ? $value : null; } // ─── Koniec kroku 2 ────────────────────────────────────────────── // ─── Krok 3: Operacje na produktach ────────────────────────────── /** * Usuwa produkt i wszystkie powiązane dane. * * @param int $productId ID produktu * @return bool */ public function delete(int $productId): bool { // Usuń kombinacje (produkty z parent_id = productId) $combinations = $this->db->select( 'pp_shop_products', 'id', [ 'parent_id' => $productId ] ); if ( $combinations ) { foreach ( $combinations as $combId ) { $this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $combId ] ); $this->db->delete( 'pp_shop_products', [ 'id' => $combId ] ); } } $this->db->delete( 'pp_shop_products_categories', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products_langs', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products_images', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products_files', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products', [ 'id' => $productId ] ); $this->db->delete( 'pp_shop_product_sets_products', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_routes', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_redirects', [ 'product_id' => $productId ] ); \Shared\Helpers\Helpers::delete_dir( '../upload/product_images/product_' . $productId . '/' ); \Shared\Helpers\Helpers::delete_dir( '../upload/product_files/product_' . $productId . '/' ); return true; } /** * Duplikuje produkt z opcjonalnym kopiowaniem kombinacji. * * @param int $productId ID produktu do zduplikowania * @param bool $withCombinations Czy kopiować kombinacje * @return bool */ public function duplicate(int $productId, bool $withCombinations = false): bool { $product = $this->db->get( 'pp_shop_products', '*', [ 'id' => $productId ] ); if ( !$product ) { return false; } $this->db->insert( 'pp_shop_products', [ 'price_netto' => $product['price_netto'], 'price_brutto' => $product['price_brutto'], 'price_netto_promo' => $product['price_netto_promo'], 'price_brutto_promo' => $product['price_brutto_promo'], 'vat' => $product['vat'], 'promoted' => $product['promoted'], 'layout_id' => $product['layout_id'], 'new_to_date' => $product['new_to_date'], 'stock_0_buy' => $product['stock_0_buy'], 'wp' => $product['wp'], 'custom_label_0' => $product['custom_label_0'], 'custom_label_1' => $product['custom_label_1'], 'custom_label_2' => $product['custom_label_2'], 'custom_label_3' => $product['custom_label_3'], 'custom_label_4' => $product['custom_label_4'], 'additional_message' => $product['additional_message'], ] ); $newProductId = $this->db->id(); if ( !$newProductId ) { return false; } // Atrybuty $attributes = $this->db->select( 'pp_shop_products_attributes', '*', [ 'product_id' => $productId ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $attributes ) ) { foreach ( $attributes as $row ) { $this->db->insert( 'pp_shop_products_attributes', [ 'product_id' => $newProductId, 'attribute_id' => $row['attribute_id'], 'value_id' => $row['value_id'], ] ); } } // Kategorie $categories = $this->db->select( 'pp_shop_products_categories', '*', [ 'product_id' => $productId ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $categories ) ) { foreach ( $categories as $row ) { $this->db->insert( 'pp_shop_products_categories', [ 'product_id' => $newProductId, 'category_id' => $row['category_id'], 'o' => $row['o'], ] ); } } // Języki $langs = $this->db->select( 'pp_shop_products_langs', '*', [ 'product_id' => $productId ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $langs ) ) { foreach ( $langs as $row ) { $this->db->insert( 'pp_shop_products_langs', [ 'product_id' => $newProductId, 'lang_id' => $row['lang_id'], 'name' => $row['name'] . ' - kopia', 'short_description' => $row['short_description'], 'description' => $row['description'], 'tab_name_1' => $row['tab_name_1'], 'tab_description_1' => $row['tab_description_1'], 'tab_name_2' => $row['tab_name_2'], 'tab_description_2' => $row['tab_description_2'], 'meta_description' => $row['meta_description'], 'meta_keywords' => $row['meta_keywords'], 'copy_from' => $row['copy_from'], 'warehouse_message_zero' => $row['warehouse_message_zero'], 'warehouse_message_nonzero' => $row['warehouse_message_nonzero'], ] ); } } // Custom fields $customFields = $this->db->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $customFields ) ) { foreach ( $customFields as $row ) { $this->db->insert( 'pp_shop_products_custom_fields', [ 'id_product' => $newProductId, 'name' => $row['name'], ] ); } } // Duplikowanie kombinacji if ( $withCombinations ) { $productCombinations = $this->db->select( 'pp_shop_products', '*', [ 'parent_id' => $productId ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $productCombinations ) ) { foreach ( $productCombinations as $comb ) { $this->db->insert( 'pp_shop_products', [ 'parent_id' => $newProductId, 'permutation_hash' => $comb['permutation_hash'], 'price_netto' => $comb['price_netto'], 'price_brutto' => $comb['price_brutto'], 'price_netto_promo' => $comb['price_netto_promo'], 'price_brutto_promo' => $comb['price_brutto_promo'], 'vat' => $comb['vat'], 'stock_0_buy' => $comb['stock_0_buy'], 'quantity' => $comb['quantity'], 'wp' => $comb['wp'], 'additional_message' => $comb['additional_message'], 'additional_message_text' => $comb['additional_message_text'], 'additional_message_required' => $comb['additional_message_required'], ] ); $combNewId = $this->db->id(); if ( $combNewId ) { $combAttrs = $this->db->select( 'pp_shop_products_attributes', '*', [ 'product_id' => $comb['id'] ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $combAttrs ) ) { foreach ( $combAttrs as $attr ) { $this->db->insert( 'pp_shop_products_attributes', [ 'product_id' => $combNewId, 'attribute_id' => $attr['attribute_id'], 'value_id' => $attr['value_id'], ] ); } } } } } } return true; } /** * Przełącza status produktu (aktywny/nieaktywny). * * @param int $productId ID produktu * @return bool */ public function toggleStatus(int $productId): bool { $status = $this->db->get( 'pp_shop_products', 'status', [ 'id' => $productId ] ); $newStatus = $status == 1 ? 0 : 1; $this->db->update( 'pp_shop_products', [ 'status' => $newStatus ], [ 'id' => $productId ] ); return true; } /** * Szybka zmiana ceny brutto produktu. * * @param int $productId ID produktu * @param mixed $price Nowa cena brutto * @return bool */ public function updatePriceBrutto(int $productId, $price): bool { $vat = (float) $this->db->get( 'pp_shop_products', 'vat', [ 'id' => $productId ] ); $priceNetto = \Shared\Helpers\Helpers::normalize_decimal( (float) $price / ( 100 + $vat ) * 100, 2 ); $this->db->update( 'pp_shop_products', [ 'price_brutto' => $price != 0.00 ? $price : null, 'price_netto' => $priceNetto != 0.00 ? $priceNetto : null, ], [ 'id' => $productId ] ); return true; } /** * Szybka zmiana ceny promocyjnej brutto produktu. * * @param int $productId ID produktu * @param mixed $price Nowa cena promo brutto * @return bool */ public function updatePriceBruttoPromo(int $productId, $price): bool { $vat = (float) $this->db->get( 'pp_shop_products', 'vat', [ 'id' => $productId ] ); $priceNetto = \Shared\Helpers\Helpers::normalize_decimal( (float) $price / ( 100 + $vat ) * 100, 2 ); $this->db->update( 'pp_shop_products', [ 'price_brutto_promo' => $price != 0.00 ? $price : null, 'price_netto_promo' => $priceNetto != 0.00 ? $priceNetto : null, ], [ 'id' => $productId ] ); return true; } /** * Szybka zmiana custom label produktu. * * @param int $productId ID produktu * @param string $label Numer labela (0-4) * @param mixed $value Wartość * @return bool */ public function updateCustomLabel(int $productId, string $label, $value): bool { $this->db->update( 'pp_shop_products', [ 'custom_label_' . $label => $value ? $value : null, ], [ 'id' => $productId ] ); return true; } // ─── Koniec kroku 3 ────────────────────────────────────────────── // ─── Krok 4: Kombinacje ────────────────────────────────────────── /** * Pobiera kombinacje produktu — lekka wersja do tabeli admin. * Jedno zapytanie SQL zamiast N×7 (findForAdmin per wiersz). * * @param int $productId ID produktu nadrzędnego * @return array */ public function getCombinationsForTable(int $productId): array { $stmt = $this->db->query( 'SELECT id, permutation_hash, sku, quantity, price_netto, stock_0_buy, parent_id FROM pp_shop_products WHERE parent_id = :pid ORDER BY id ASC', [ ':pid' => $productId ] ); return $stmt ? $stmt->fetchAll( \PDO::FETCH_ASSOC ) : []; } /** * Pobiera permutacje (kombinacje) produktu z pełnymi danymi. * * @param int $productId ID produktu nadrzędnego * @return array */ public function getPermutations(int $productId): array { $products = []; $results = $this->db->select( 'pp_shop_products', 'id', [ 'parent_id' => $productId ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $results ) ) { foreach ( $results as $row ) { $detail = $this->findForAdmin( (int) $row ); if ( $detail ) { $products[] = $detail; } } } return $products; } /** * Generuje kombinacje produktu na podstawie atrybutów. * * @param int $productId ID produktu nadrzędnego * @param array $attributes Tablica atrybutów [attribute_id => [value_id, ...], ...] * @return bool */ public function generateCombinations(int $productId, array $attributes): bool { $vat = $this->db->get( 'pp_shop_products', 'vat', [ 'id' => $productId ] ); $attributeRepository = new \Domain\Attribute\AttributeRepository( $this->db ); $permutations = \shop\Product::array_cartesian( $attributes ); if ( !\Shared\Helpers\Helpers::is_array_fix( $permutations ) ) { return true; } foreach ( $permutations as $permutation ) { $product = null; ksort( $permutation ); $permutationHash = ''; if ( \Shared\Helpers\Helpers::is_array_fix( $permutation ) ) { foreach ( $permutation as $key => $val ) { if ( $permutationHash ) { $permutationHash .= '|'; } $permutationHash .= $key . '-' . $val; $valueDetails = $attributeRepository->valueDetails( (int) $val ); $impactOnPrice = $valueDetails['impact_on_the_price']; if ( $impactOnPrice > 0 ) { if ( !$product ) { $product = $this->findForAdmin( $productId ); } $productPriceBrutto = $product['price_brutto'] + $impactOnPrice; $productPriceNetto = $productPriceBrutto / ( 1 + ( $product['vat'] / 100 ) ); if ( $product['price_brutto_promo'] ) { $productPriceBruttoPromo = $product['price_brutto_promo'] + $impactOnPrice; $productPriceNettoPromo = $productPriceBruttoPromo / ( 1 + ( $product['vat'] / 100 ) ); } else { $productPriceBruttoPromo = null; $productPriceNettoPromo = null; } } if ( $permutationHash && !$this->db->count( 'pp_shop_products', [ 'AND' => [ 'parent_id' => $productId, 'permutation_hash' => $permutationHash ] ] ) ) { if ( $this->db->insert( 'pp_shop_products', [ 'parent_id' => $productId, 'permutation_hash' => $permutationHash, 'vat' => $vat ] ) ) { $combinationId = $this->db->id(); if ( $product ) { $this->db->update( 'pp_shop_products', [ 'price_netto' => $productPriceNetto, 'vat' => $product['vat'], 'price_brutto' => $productPriceBrutto, 'price_netto_promo' => $productPriceNettoPromo ?? null, 'price_brutto_promo' => $productPriceBruttoPromo ?? null, ], [ 'id' => $combinationId ] ); } $hashRows = explode( '|', $permutationHash ); foreach ( $hashRows as $hashRow ) { $attrRev = explode( '-', $hashRow ); $this->db->insert( 'pp_shop_products_attributes', [ 'product_id' => $combinationId, 'attribute_id' => $attrRev[0], 'value_id' => $attrRev[1], ] ); } } } } } } return true; } /** * Usuwa kombinację produktu. * * @param int $combinationId ID kombinacji * @return bool */ public function deleteCombination(int $combinationId): bool { $this->db->delete( 'pp_shop_products', [ 'id' => $combinationId ] ); $this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $combinationId ] ); return true; } /** * Liczy kombinacje produktu. * * @param int $productId ID produktu nadrzędnego * @return int */ public function countCombinations(int $productId): int { return (int) $this->db->count( 'pp_shop_products', [ 'parent_id' => $productId ] ); } /** * Zapisuje stock_0_buy kombinacji. */ public function saveCombinationStock0Buy(int $productId, $value): bool { $this->db->update( 'pp_shop_products', [ 'stock_0_buy' => $value == 'true' ? 1 : 0 ], [ 'id' => $productId ] ); return true; } /** * Zapisuje SKU kombinacji. */ public function saveCombinationSku(int $productId, $sku): bool { $this->db->update( 'pp_shop_products', [ 'sku' => $sku ], [ 'id' => $productId ] ); return true; } /** * Zapisuje ilość kombinacji. */ public function saveCombinationQuantity(int $productId, $quantity): bool { $this->db->update( 'pp_shop_products', [ 'quantity' => $quantity == '' ? null : (int) $quantity, ], [ 'id' => $productId ] ); return true; } /** * Zapisuje cenę kombinacji (z przeliczeniem netto -> brutto). */ public function saveCombinationPrice(int $productId, $priceNetto): bool { $vat = $this->db->get( 'pp_shop_products', 'vat', [ 'id' => $productId ] ); $priceBrutto = (float) $priceNetto * ( 1 + ( (float) $vat / 100 ) ); $this->db->update( 'pp_shop_products', [ 'price_netto' => $priceNetto == '' ? null : (float) $priceNetto, 'price_brutto' => $priceBrutto == '' ? null : (float) $priceBrutto, ], [ 'id' => $productId ] ); return true; } // ─── Koniec kroku 4 ────────────────────────────────────────────── // ─── Krok 5: Zdjęcia / Pliki / XML ────────────────────────────── /** * Oznacza zdjęcie do usunięcia. */ public function deleteImage(int $imageId): bool { $this->db->update( 'pp_shop_products_images', [ 'to_delete' => 1 ], [ 'id' => $imageId ] ); return true; } /** * Zmienia atrybut alt zdjęcia. */ public function updateImageAlt(int $imageId, string $alt): bool { $result = $this->db->update( 'pp_shop_products_images', [ 'alt' => $alt ], [ 'id' => $imageId ] ); \Shared\Helpers\Helpers::delete_cache(); return (bool) $result; } /** * Zapisuje kolejność zdjęć. */ public function saveImagesOrder(int $productId, string $order): bool { $orderArr = explode( ';', $order ); if ( is_array( $orderArr ) && !empty( $orderArr ) ) { $i = 0; foreach ( $orderArr as $imageId ) { $this->db->update( 'pp_shop_products_images', [ 'o' => $i++, ], [ 'AND' => [ 'product_id' => $productId, 'id' => $imageId, ], ] ); } } return true; } /** * Usuwa nieprzypisane zdjęcia (product_id = null). */ public function deleteNonassignedImages(): void { $results = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => null ] ); if ( is_array( $results ) ) { foreach ( $results as $row ) { if ( file_exists( '../' . $row['src'] ) ) { unlink( '../' . $row['src'] ); } } } $this->db->delete( 'pp_shop_products_images', [ 'product_id' => null ] ); } /** * Oznacza plik do usunięcia. */ public function deleteFile(int $fileId): bool { $this->db->update( 'pp_shop_products_files', [ 'to_delete' => 1 ], [ 'id' => $fileId ] ); return true; } /** * Zmienia nazwę pliku. */ public function updateFileName(int $fileId, string $name): bool { $this->db->update( 'pp_shop_products_files', [ 'name' => $name ], [ 'id' => $fileId ] ); return true; } /** * Usuwa nieprzypisane pliki (product_id = null). */ public function deleteNonassignedFiles(): void { $results = $this->db->select( 'pp_shop_products_files', '*', [ 'product_id' => null ] ); if ( is_array( $results ) ) { foreach ( $results as $row ) { if ( file_exists( '../' . $row['src'] ) ) { unlink( '../' . $row['src'] ); } } } $this->db->delete( 'pp_shop_products_files', [ 'product_id' => null ] ); } /** * Pobiera zdjęcia produktu. */ public function getProductImages(int $productId): array { return $this->db->select( 'pp_shop_products_images', 'src', [ 'product_id' => $productId, 'ORDER' => [ 'o' => 'ASC', 'id' => 'ASC' ], ] ) ?: []; } /** * Zapisuje nazwę XML produktu. */ public function saveXmlName(int $productId, string $xmlName, string $langId): bool { return (bool) $this->db->update( 'pp_shop_products_langs', [ 'xml_name' => $xmlName, ], [ 'AND' => [ 'product_id' => $productId, 'lang_id' => $langId ], ] ); } /** * Pobiera sugestie custom label. */ public function customLabelSuggestions(string $customLabel, string $labelType): array { $output = []; $results = $this->db->query( 'SELECT DISTINCT ' . $labelType . ' AS label FROM pp_shop_products WHERE ' . $labelType . ' LIKE :custom_label LIMIT 10', [ ':custom_label' => '%' . $customLabel . '%' ] ); if ( $results ) { foreach ( $results->fetchAll( \PDO::FETCH_ASSOC ) as $row ) { $output[] = $row; } } return $output; } /** * Zapisuje custom label produktu. */ public function saveCustomLabel(int $productId, string $customLabel, string $labelType): bool { return (bool) $this->db->update( 'pp_shop_products', [ $labelType => $customLabel ], [ 'id' => $productId ] ); } /** * Generuje kod EAN. */ public function generateEAN(string $number): string { $code = '200' . str_pad( $number, 9, '0' ); $weightflag = true; $sum = 0; for ( $i = strlen( $code ) - 1; $i >= 0; $i-- ) { $sum += (int) $code[$i] * ( $weightflag ? 3 : 1 ); $weightflag = !$weightflag; } $code .= ( 10 - ( $sum % 10 ) ) % 10; return $code; } /** * Generuje Google Feed XML. */ public function generateGoogleFeedXml(): void { global $lang_id; $settings = ( new \Domain\Settings\SettingsRepository( $this->db ) )->allSettings( true ); $domainPrefix = 'https'; $url = preg_replace( '#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME'] ); $doc = new \DOMDocument( '1.0', 'UTF-8' ); $xmlRoot = $doc->createElement( 'rss' ); $doc->appendChild( $xmlRoot ); $xmlRoot->setAttribute( 'version', '2.0' ); $xmlRoot->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:g', 'http://base.google.com/ns/1.0' ); $channelNode = $xmlRoot->appendChild( $doc->createElement( 'channel' ) ); $channelNode->appendChild( $doc->createElement( 'title', $settings['firm_name'] ) ); $channelNode->appendChild( $doc->createElement( 'link', $domainPrefix . '://' . $url ) ); $rows = $this->db->select( 'pp_shop_products', 'id', [ 'AND' => [ 'status' => '1', 'archive' => 0, 'parent_id' => null ], ] ); if ( \Shared\Helpers\Helpers::is_array_fix( $rows ) ) { foreach ( $rows as $productId ) { $product = \shop\Product::getFromCache( $productId, $lang_id ); if ( is_array( $product->product_combinations ) && count( $product->product_combinations ) ) { foreach ( $product->product_combinations as $productCombination ) { if ( $productCombination->quantity !== null || $productCombination->stock_0_buy ) { $this->appendCombinationToXml( $doc, $channelNode, $product, $productCombination, $domainPrefix, $url ); } } } else { $this->appendProductToXml( $doc, $channelNode, $product, $domainPrefix, $url ); } } } file_put_contents( '../google-feed.xml', $doc->saveXML() ); } /** * Dodaje kombinację produktu do XML feed. */ private function appendCombinationToXml(\DOMDocument $doc, \DOMElement $channelNode, $product, $combination, string $domainPrefix, string $url): void { $itemNode = $channelNode->appendChild( $doc->createElement( 'item' ) ); $itemNode->appendChild( $doc->createElement( 'g:id', $combination->id ) ); $itemNode->appendChild( $doc->createElement( 'g:item_group_id', $product->id ) ); for ( $l = 0; $l <= 4; $l++ ) { $label = 'custom_label_' . $l; if ( $product->$label ) { $itemNode->appendChild( $doc->createElement( 'g:' . $label, $product->$label ) ); } } $title = $product->language['xml_name'] ?: $product->language['name']; $itemNode->appendChild( $doc->createElement( 'title', str_replace( '&', '&', $title ) . ' - ' . $product->generateSubtitleFromAttributes( $combination->permutation_hash ) ) ); $gtin = $product->ean ?: $this->generateEAN( $product->id ); $itemNode->appendChild( $doc->createElement( 'g:gtin', $gtin ) ); $desc = $product->language['short_description'] ?: $product->language['name']; $itemNode->appendChild( $doc->createElement( 'g:description', html_entity_decode( strip_tags( $desc ) ) ) ); if ( $product->language['seo_link'] ) { $link = $domainPrefix . '://' . $url . '/' . \Shared\Helpers\Helpers::seo( $product->language['seo_link'] ) . '/' . str_replace( '|', '/', $combination->permutation_hash ); } else { $link = $domainPrefix . '://' . $url . '/p-' . $product->id . '-' . \Shared\Helpers\Helpers::seo( $product->language['name'] ) . '/' . str_replace( '|', '/', $combination->permutation_hash ); } $itemNode->appendChild( $doc->createElement( 'link', $link ) ); $this->appendImagesToXml( $doc, $itemNode, $product, $domainPrefix, $url ); $itemNode->appendChild( $doc->createElement( 'g:condition', 'new' ) ); if ( $combination->quantity !== null ) { if ( $combination->quantity > 0 ) { $itemNode->appendChild( $doc->createElement( 'g:availability', 'in stock' ) ); $itemNode->appendChild( $doc->createElement( 'g:quantity', $combination->quantity ) ); } else { $itemNode->appendChild( $doc->createElement( 'g:availability', $combination->stock_0_buy ? 'in stock' : 'out of stock' ) ); } } else { if ( $product->quantity > 0 ) { $itemNode->appendChild( $doc->createElement( 'g:availability', 'in stock' ) ); $itemNode->appendChild( $doc->createElement( 'g:quantity', $product->quantity ) ); } else { $itemNode->appendChild( $doc->createElement( 'g:availability', $product->stock_0_buy ? 'in stock' : 'out of stock' ) ); $itemNode->appendChild( $doc->createElement( 'g:quantity', $product->stock_0_buy ? 999 : 0 ) ); } } if ( $combination->price_brutto ) { $itemNode->appendChild( $doc->createElement( 'g:price', $combination->price_brutto . ' PLN' ) ); if ( $combination->price_brutto_promo ) { $itemNode->appendChild( $doc->createElement( 'g:sale_price', $combination->price_brutto_promo . ' PLN' ) ); } } else { $itemNode->appendChild( $doc->createElement( 'g:price', $product->price_brutto . ' PLN' ) ); if ( $product->price_brutto_promo ) { $itemNode->appendChild( $doc->createElement( 'g:sale_price', $product->price_brutto_promo . ' PLN' ) ); } } $this->appendShippingToXml( $doc, $itemNode, $product ); } /** * Dodaje produkt (bez kombinacji) do XML feed. */ private function appendProductToXml(\DOMDocument $doc, \DOMElement $channelNode, $product, string $domainPrefix, string $url): void { $itemNode = $channelNode->appendChild( $doc->createElement( 'item' ) ); $itemNode->appendChild( $doc->createElement( 'g:id', $product->id ) ); $itemNode->appendChild( $doc->createElement( 'g:item_group_id', $product->id ) ); if ( $product->google_xml_label ) { $itemNode->appendChild( $doc->createElement( 'g:custom_label_0', $product->google_xml_label ) ); } $title = $product->language['xml_name'] ?: $product->language['name']; $itemNode->appendChild( $doc->createElement( 'title', str_replace( '&', '&', $title ) ) ); $gtin = $product->ean ?: $this->generateEAN( $product->id ); $itemNode->appendChild( $doc->createElement( 'g:gtin', $gtin ) ); $desc = $product->language['short_description'] ?: $product->language['name']; $itemNode->appendChild( $doc->createElement( 'g:description', html_entity_decode( strip_tags( $desc ) ) ) ); if ( $product->language['seo_link'] ) { $link = $domainPrefix . '://' . $url . '/' . \Shared\Helpers\Helpers::seo( $product->language['seo_link'] ); } else { $link = $domainPrefix . '://' . $url . '/p-' . $product->id . '-' . \Shared\Helpers\Helpers::seo( $product->language['name'] ); } $itemNode->appendChild( $doc->createElement( 'link', $link ) ); for ( $l = 0; $l <= 4; $l++ ) { $label = 'custom_label_' . $l; if ( $product->$label ) { $itemNode->appendChild( $doc->createElement( 'g:' . $label, $product->$label ) ); } } $this->appendImagesToXml( $doc, $itemNode, $product, $domainPrefix, $url ); $itemNode->appendChild( $doc->createElement( 'g:condition', 'new' ) ); if ( $product->quantity ) { $itemNode->appendChild( $doc->createElement( 'g:availability', 'in stock' ) ); $itemNode->appendChild( $doc->createElement( 'g:quantity', $product->quantity ) ); } else { if ( $product->stock_0_buy ) { $itemNode->appendChild( $doc->createElement( 'g:availability', 'in stock' ) ); $itemNode->appendChild( $doc->createElement( 'g:quantity', 999 ) ); } else { $itemNode->appendChild( $doc->createElement( 'g:availability', 'out of stock' ) ); $itemNode->appendChild( $doc->createElement( 'g:quantity', 0 ) ); } } $itemNode->appendChild( $doc->createElement( 'g:price', $product->price_brutto . ' PLN' ) ); if ( $product->price_brutto_promo ) { $itemNode->appendChild( $doc->createElement( 'g:sale_price', $product->price_brutto_promo . ' PLN' ) ); } $this->appendShippingToXml( $doc, $itemNode, $product ); } /** * Dodaje zdjęcia do node XML. */ private function appendImagesToXml(\DOMDocument $doc, \DOMElement $itemNode, $product, string $domainPrefix, string $url): void { if ( isset( $product->images[0] ) ) { $itemNode->appendChild( $doc->createElement( 'g:image_link', $domainPrefix . '://' . $url . $product->images[0]['src'] ) ); } if ( count( $product->images ) > 1 ) { for ( $i = 1; $i < count( $product->images ); ++$i ) { $itemNode->appendChild( $doc->createElement( 'g:additional_image_link', $domainPrefix . '://' . $url . $product->images[$i]['src'] ) ); } } } /** * Dodaje sekcję shipping do node XML. */ private function appendShippingToXml(\DOMDocument $doc, \DOMElement $itemNode, $product): void { $shippingNode = $itemNode->appendChild( $doc->createElement( 'g:shipping' ) ); $shippingNode->appendChild( $doc->createElement( 'g:country', 'PL' ) ); $shippingNode->appendChild( $doc->createElement( 'g:service', '1 dzień roboczy' ) ); $shippingNode->appendChild( $doc->createElement( 'g:price', ( new \Domain\Transport\TransportRepository( $this->db ) )->lowestTransportPrice( (int) $product->wp ) . ' PLN' ) ); } // ─── Koniec kroku 5 ────────────────────────────────────────────── /** * Aktualizuje ceny kombinacji produktu na podstawie cen brutto bazowych. * Wywoływane z saveProduct() i z cron.php. * * @param int $productId ID produktu nadrzędnego * @param float $priceBrutto Cena brutto bazowa * @param float $vat Stawka VAT * @param float|null $priceBruttoPromo Cena promo brutto bazowa (null = brak) */ public function updateCombinationPricesFromBase(int $productId, $priceBrutto, $vat, $priceBruttoPromo): void { $priceBrutto = (float) $priceBrutto; $vat = (float) $vat; $priceBruttoPromo = $priceBruttoPromo ? (float) $priceBruttoPromo : null; $combinations = $this->db->query( 'SELECT psp.id ' . 'FROM pp_shop_products AS psp ' . 'INNER JOIN pp_shop_products_attributes AS pspa ON psp.id = pspa.product_id ' . 'INNER JOIN pp_shop_attributes_values AS psav ON pspa.value_id = psav.id ' . 'WHERE psav.impact_on_the_price > 0 AND psp.parent_id = :product_id', [ ':product_id' => $productId ] ); if ( !$combinations ) { return; } $rows = $combinations->fetchAll( \PDO::FETCH_ASSOC ); foreach ( $rows as $row ) { $combBrutto = $priceBrutto; $combBruttoPromo = $priceBruttoPromo; $values = $this->db->query( 'SELECT impact_on_the_price FROM pp_shop_attributes_values AS psav ' . 'INNER JOIN pp_shop_products_attributes AS pspa ON pspa.value_id = psav.id ' . 'WHERE impact_on_the_price IS NOT NULL AND product_id = :product_id', [ ':product_id' => $row['id'] ] ); if ( $values ) { foreach ( $values->fetchAll( \PDO::FETCH_ASSOC ) as $value ) { $combBrutto += $value['impact_on_the_price']; if ( $combBruttoPromo !== null ) { $combBruttoPromo += $value['impact_on_the_price']; } else { $combBruttoPromo = null; } } } $combNetto = \Shared\Helpers\Helpers::normalize_decimal( $combBrutto / ( 100 + $vat ) * 100, 2 ); $combNettoPromo = $combBruttoPromo !== null ? \Shared\Helpers\Helpers::normalize_decimal( $combBruttoPromo / ( 100 + $vat ) * 100, 2 ) : null; $this->db->update( 'pp_shop_products', [ 'price_netto' => $combNetto, 'price_brutto' => $combBrutto, 'price_netto_promo' => $combNettoPromo, 'price_brutto_promo' => $combBruttoPromo ], [ 'id' => $row['id'] ] ); } } /** * Aktualizuje ceny kombinacji produktu uwzględniając wpływ na cenę (impact_on_the_price). * Wersja prywatna — przyjmuje ceny netto (dla mass_edit / applyDiscountPercent). * * @param int $productId ID produktu nadrzędnego * @param float $priceNetto Cena netto bazowa * @param float $vat Stawka VAT * @param float|null $priceNettoPromo Cena promo netto bazowa (null = brak) */ private function updateCombinationPrices(int $productId, float $priceNetto, float $vat, ?float $priceNettoPromo): void { $priceBrutto = \Shared\Helpers\Helpers::normalize_decimal( $priceNetto * ( 100 + $vat ) / 100, 2 ); $priceBruttoPromo = $priceNettoPromo !== null ? \Shared\Helpers\Helpers::normalize_decimal( $priceNettoPromo * ( 100 + $vat ) / 100, 2 ) : null; $combinations = $this->db->query( 'SELECT psp.id ' . 'FROM pp_shop_products AS psp ' . 'INNER JOIN pp_shop_products_attributes AS pspa ON psp.id = pspa.product_id ' . 'INNER JOIN pp_shop_attributes_values AS psav ON pspa.value_id = psav.id ' . 'WHERE psav.impact_on_the_price > 0 AND psp.parent_id = :product_id', [ ':product_id' => $productId ] ); if ( !$combinations ) { return; } $rows = $combinations->fetchAll( \PDO::FETCH_ASSOC ); foreach ( $rows as $row ) { $combBrutto = $priceBrutto; $combBruttoPromo = $priceBruttoPromo; $values = $this->db->query( 'SELECT impact_on_the_price FROM pp_shop_attributes_values AS psav ' . 'INNER JOIN pp_shop_products_attributes AS pspa ON pspa.value_id = psav.id ' . 'WHERE impact_on_the_price IS NOT NULL AND product_id = :product_id', [ ':product_id' => $row['id'] ] ); if ( $values ) { foreach ( $values->fetchAll( \PDO::FETCH_ASSOC ) as $value ) { $combBrutto += $value['impact_on_the_price']; if ( $combBruttoPromo !== null ) { $combBruttoPromo += $value['impact_on_the_price']; } } } $combNetto = \Shared\Helpers\Helpers::normalize_decimal( $combBrutto / ( 100 + $vat ) * 100, 2 ); $combNettoPromo = $combBruttoPromo !== null ? \Shared\Helpers\Helpers::normalize_decimal( $combBruttoPromo / ( 100 + $vat ) * 100, 2 ) : null; $this->db->update( 'pp_shop_products', [ 'price_netto' => $combNetto, 'price_brutto' => $combBrutto, 'price_netto_promo' => $combNettoPromo, 'price_brutto_promo' => $combBruttoPromo ], [ 'id' => $row['id'] ] ); } } }