Files
shopPRO/autoload/Domain/Product/ProductRepository.php
Jacek Pyziak 0402dbee76 ver. 0.280: Articles frontend migration, class.Article removal, Settings facade cleanup
- Add 8 frontend methods to ArticleRepository (with Redis cache)
- Create front\Views\Articles (rendering + utility methods)
- Rewire front\view\Site::show() and front\controls\Site::route() to repo + Views
- Update 5 article templates to use \front\Views\Articles::
- Convert front\factory\Articles and front\view\Articles to facades
- Remove class.Article (entity + static methods migrated to repo + Views)
- Remove front\factory\Settings facade (already migrated)
- Fix: eliminate global $lang from articleNoindex(), inline page sort query
- Tests: 450 OK, 1431 assertions (+13 new)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:52:03 +01:00

1852 lines
73 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Domain\Product;
/**
* Repository odpowiedzialny za dostęp do danych produktów
*
* Zgodnie z wzorcem Repository Pattern, ta klasa enkapsuluje
* logikę dostępu do bazy danych dla produktów.
*/
class ProductRepository
{
private const MAX_PER_PAGE = 100;
/**
* @var \medoo Instancja Medoo ORM
*/
private $db;
/**
* Konstruktor - przyjmuje instancję bazy danych
*
* @param \medoo $db Instancja Medoo ORM
*/
public function __construct($db)
{
$this->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<int, array<string, mixed>>, 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<int, string> 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 );
}
\S::htacces();
\S::delete_dir( '../temp/' );
\S::delete_dir( '../thumbs/' );
if ( !$isNew ) {
$redis = \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] ) ? \S::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'] ?: \S::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 ( \S::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 ] );
\S::delete_dir( '../upload/product_images/product_' . $productId . '/' );
\S::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 ( \S::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 ( \S::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 ( \S::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 ( \S::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 ( \S::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 ( \S::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 = \S::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 = \S::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<int, array{id: int, permutation_hash: string, sku: ?string, quantity: ?int, price_netto: ?float, stock_0_buy: int, parent_id: int}>
*/
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 ( \S::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 ( !\S::is_array_fix( $permutations ) ) {
return true;
}
foreach ( $permutations as $permutation ) {
$product = null;
ksort( $permutation );
$permutationHash = '';
if ( \S::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 ] );
\S::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 ( \S::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( '&', '&amp;', $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 . '/' . \S::seo( $product->language['seo_link'] ) . '/' . str_replace( '|', '/', $combination->permutation_hash );
} else {
$link = $domainPrefix . '://' . $url . '/p-' . $product->id . '-' . \S::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( '&', '&amp;', $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 . '/' . \S::seo( $product->language['seo_link'] );
} else {
$link = $domainPrefix . '://' . $url . '/p-' . $product->id . '-' . \S::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 = \S::normalize_decimal( $combBrutto / ( 100 + $vat ) * 100, 2 );
$combNettoPromo = $combBruttoPromo !== null
? \S::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 = \S::normalize_decimal( $priceNetto * ( 100 + $vat ) / 100, 2 );
$priceBruttoPromo = $priceNettoPromo !== null
? \S::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 = \S::normalize_decimal( $combBrutto / ( 100 + $vat ) * 100, 2 );
$combNettoPromo = $combBruttoPromo !== null
? \S::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'] ] );
}
}
}