Compare commits

...

10 Commits

Author SHA1 Message Date
3b2d156e84 ver. 0.323: fix import zdjęć, trwałe usuwanie produktów, fix API upload path
- IntegrationsRepository: refactor importu zdjęć — walidacja HTTP, curl timeouty, logi, czytelny komunikat
- ProductRepository: saveCustomFields tylko gdy klucz istnieje (partial API update), delete() czyści custom_fields
- ProductArchiveController: przycisk i metoda delete_permanent() do trwałego usunięcia z archiwum
- ProductsApiController: fix ścieżki upload (api.php działa z rootu projektu)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:05:23 +01:00
c44f59894e build: update package v0.322
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:13:19 +01:00
fe2a77e995 ver. 0.322: fix custom_fields — jawne mapowanie kluczy w ProductRepository, spójne !empty w ProductsApiController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:11:50 +01:00
f0b1152ab1 build: update package v0.321
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:00:08 +01:00
44ac25b063 ver. 0.321: API produkty — obsługa custom_fields w create/update
- ProductsApiController: parsowanie custom_fields z body (name, type, is_required)
- Zaktualizowano docs/API.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:54:33 +01:00
ee8459ca2a build: update package v0.320
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:35:13 +01:00
8e2e070eb7 ver. 0.320: API słowniki — ensure_producer; ProductRepository — producer_name w odpowiedzi
- DictionariesApiController: nowy endpoint POST ensure_producer (znajdź lub utwórz producenta)
- ProducerRepository: metoda ensureProducerForApi()
- ProductRepository: pole producer_name w odpowiedzi GET product
- ApiRouter: wstrzyknięto ProducerRepository do DictionariesApiController
- Zaktualizowano docs/API.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:32:25 +01:00
ec4e25946d build: update package v0.319
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 12:35:35 +01:00
4f66dbe42c ver. 0.319: usunięcie shopPRO eksportu produktów + rozszerzenie API o custom_fields i security_information
- Usunięto shopproExportProduct() z IntegrationsRepository
- Usunięto shoppro_product_export() z IntegrationsController
- Usunięto przycisk "Eksportuj do shopPRO" z ShopProductController
- ProductRepository: dodano custom_fields i security_information do odpowiedzi API
- Zaktualizowano docs/API.md i testy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 12:29:13 +01:00
4e720c5689 build: update package v0.318
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 11:59:04 +01:00
27 changed files with 432 additions and 223 deletions

View File

@@ -747,26 +747,55 @@ class IntegrationsRepository
// Import images
$images = $mdb2->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] );
$importLog = [];
$domainRaw = preg_replace( '#^https?://#', '', (string)($settings['domain'] ?? '') );
if ( is_array( $images ) ) {
foreach ( $images as $image ) {
$imageUrl = 'https://' . $settings['domain'] . $image['src'];
$srcPath = (string)($image['src'] ?? '');
$imageUrl = 'https://' . rtrim( $domainRaw, '/' ) . '/' . ltrim( $srcPath, '/' );
$imageName = basename( $srcPath );
if ( $imageName === '' ) {
$importLog[] = '[SKIP] Pusta nazwa pliku dla src: ' . $srcPath;
continue;
}
$ch = curl_init( $imageUrl );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false );
$imageData = curl_exec( $ch );
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
$imageData = curl_exec( $ch );
$httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$curlErrno = curl_errno( $ch );
$curlError = curl_error( $ch );
curl_close( $ch );
$imageName = basename( $imageUrl );
$imageDir = '../upload/product_images/product_' . $newProductId;
if ( $curlErrno !== 0 || $imageData === false ) {
$importLog[] = '[ERROR] cURL: ' . $imageUrl . ' — błąd ' . $curlErrno . ': ' . $curlError;
continue;
}
if ( $httpCode !== 200 ) {
$importLog[] = '[ERROR] HTTP ' . $httpCode . ': ' . $imageUrl;
continue;
}
$imageDir = dirname( __DIR__, 3 ) . '/upload/product_images/product_' . $newProductId;
$imagePath = $imageDir . '/' . $imageName;
if ( !file_exists( $imageDir ) )
mkdir( $imageDir, 0777, true );
if ( !file_exists( $imageDir ) && !mkdir( $imageDir, 0777, true ) && !file_exists( $imageDir ) ) {
$importLog[] = '[ERROR] Nie można utworzyć katalogu: ' . $imageDir;
continue;
}
file_put_contents( $imagePath, $imageData );
$written = file_put_contents( $imagePath, $imageData );
if ( $written === false ) {
$importLog[] = '[ERROR] Zapis pliku nieudany: ' . $imagePath;
continue;
}
$this->db->insert( 'pp_shop_products_images', [
'product_id' => $newProductId,
@@ -774,122 +803,50 @@ class IntegrationsRepository
'alt' => $image['alt'] ?? '',
'o' => $image['o'],
] );
$importLog[] = '[OK] ' . $imageUrl . ' → ' . $imagePath . ' (' . $written . ' B)';
}
}
return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ];
}
// Zapisz log importu zdjęć (ścieżka absolutna — niezależna od cwd)
$logDir = dirname( __DIR__, 3 ) . '/logs';
$logFile = $logDir . '/shoppro-import-debug.log';
$mkdirOk = file_exists( $logDir ) || mkdir( $logDir, 0755, true ) || file_exists( $logDir );
$logEntry = '[' . date( 'Y-m-d H:i:s' ) . '] Import produktu #' . $productId . ' → #' . $newProductId . "\n"
. ' Domain: ' . $domainRaw . "\n"
. ' Obrazy źródłowe: ' . count( $images ?: [] ) . "\n";
foreach ( $importLog as $line ) {
$logEntry .= ' ' . $line . "\n";
}
// Zawsze loguj do error_log (niezależnie od uprawnień do pliku)
error_log( '[shopPRO shoppro-import] ' . str_replace( "\n", ' | ', $logEntry ) );
// ── ShopPRO export ──────────────────────────────────────────
public function shopproExportProduct( int $productId ): array
{
$settings = $this->getSettings( 'shoppro' );
$missingSetting = $this->missingShopproSetting( $settings, [ 'db_name', 'db_host', 'db_user' ] );
if ( $missingSetting !== null ) {
return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ];
if ( $mkdirOk && file_put_contents( $logFile, $logEntry, FILE_APPEND ) === false ) {
error_log( '[shopPRO shoppro-import] WARN: nie można zapisać logu do: ' . $logFile );
} elseif ( !$mkdirOk ) {
error_log( '[shopPRO shoppro-import] WARN: nie można utworzyć katalogu: ' . $logDir );
}
$product = $this->db->get( 'pp_shop_products', '*', [ 'id' => $productId ] );
if ( !$product ) {
return [ 'success' => false, 'message' => 'Nie znaleziono produktu do eksportu.' ];
}
$mdb2 = $this->shopproDb( $settings );
$mdb2->insert( 'pp_shop_products', [
'price_netto' => $product['price_netto'] ?? null,
'price_brutto' => $product['price_brutto'] ?? null,
'vat' => $product['vat'] ?? null,
'stock_0_buy' => $product['stock_0_buy'] ?? 0,
'quantity' => $product['quantity'] ?? 0,
'wp' => $product['wp'] ?? null,
'sku' => $product['sku'] ?? '',
'ean' => $product['ean'] ?? '',
'custom_label_0' => $product['custom_label_0'] ?? null,
'custom_label_1' => $product['custom_label_1'] ?? null,
'custom_label_2' => $product['custom_label_2'] ?? null,
'custom_label_3' => $product['custom_label_3'] ?? null,
'custom_label_4' => $product['custom_label_4'] ?? null,
'additional_message' => $product['additional_message'] ?? 0,
'additional_message_text' => $product['additional_message_text'] ?? null,
'additional_message_required'=> $product['additional_message_required'] ?? 0,
'weight' => $product['weight'] ?? null,
'producer_id' => $product['producer_id'] ?? null,
] );
$newProductId = (int) $mdb2->id();
if ( $newProductId <= 0 ) {
return [ 'success' => false, 'message' => 'Podczas eksportowania produktu wystąpił błąd.' ];
}
$languages = $this->db->select( 'pp_shop_products_langs', '*', [ 'product_id' => $productId ] );
if ( is_array( $languages ) ) {
foreach ( $languages as $lang ) {
$mdb2->insert( 'pp_shop_products_langs', [
'product_id' => $newProductId,
'lang_id' => $lang['lang_id'] ?? '',
'name' => $lang['name'] ?? '',
'short_description' => $lang['short_description'] ?? null,
'description' => $lang['description'] ?? null,
'tab_name_1' => $lang['tab_name_1'] ?? null,
'tab_description_1' => $lang['tab_description_1'] ?? null,
'tab_name_2' => $lang['tab_name_2'] ?? null,
'tab_description_2' => $lang['tab_description_2'] ?? null,
'meta_title' => $lang['meta_title'] ?? null,
'meta_description' => $lang['meta_description'] ?? null,
'meta_keywords' => $lang['meta_keywords'] ?? null,
'seo_link' => $lang['seo_link'] ?? null,
'copy_from' => $lang['copy_from'] ?? null,
'warehouse_message_zero' => $lang['warehouse_message_zero'] ?? null,
'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'] ?? null,
'canonical' => $lang['canonical'] ?? null,
'xml_name' => $lang['xml_name'] ?? null,
'security_information' => $lang['security_information'] ?? null,
] );
}
}
$customFields = $this->db->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] );
if ( is_array( $customFields ) ) {
foreach ( $customFields as $field ) {
$mdb2->insert( 'pp_shop_products_custom_fields', [
'id_product' => $newProductId,
'name' => (string)($field['name'] ?? ''),
'type' => (string)($field['type'] ?? 'text'),
'is_required' => !empty( $field['is_required'] ) ? 1 : 0,
] );
}
}
$images = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] );
if ( is_array( $images ) && count( $images ) > 0 ) {
$missingImageApiSetting = $this->missingShopproSetting( $settings, [ 'domain', 'api_key' ] );
if ( $missingImageApiSetting !== null ) {
return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO dla wysylki zdjec: ' . $missingImageApiSetting . '.' ];
}
}
if ( is_array( $images ) ) {
foreach ( $images as $image ) {
$remoteImageSrc = $this->sendImageToShopproApi(
(string)($image['src'] ?? ''),
(int)$newProductId,
(string)($settings['domain'] ?? ''),
(string)($settings['api_key'] ?? ''),
(string)($image['alt'] ?? ''),
(int)($image['o'] ?? 0)
);
if ( $remoteImageSrc === '' ) {
return [ 'success' => false, 'message' => 'Nie udalo sie wyslac zdjec produktu przez API shopPRO.' ];
// Zbuduj czytelny komunikat z wynikiem importu zdjęć
$imgCount = count( $images ?: [] );
if ( $imgCount === 0 ) {
$imgSummary = 'Zdjęcia: brak w bazie źródłowej.';
} else {
$ok = 0;
$errors = [];
foreach ( $importLog as $line ) {
if ( strncmp( $line, '[OK]', 4 ) === 0 ) {
$ok++;
} else {
$errors[] = $line;
}
}
$imgSummary = 'Zdjęcia: ' . $ok . '/' . $imgCount . ' zaimportowanych.';
if ( !empty( $errors ) ) {
$imgSummary .= ' Błędy: ' . implode( '; ', $errors );
}
}
return [
'success' => true,
'message' => 'Produkt został wyeksportowany (ID: ' . $newProductId . ').',
];
return [ 'success' => true, 'message' => 'Produkt został zaimportowany. ' . $imgSummary ];
}
private function missingShopproSetting( array $settings, array $requiredKeys ): ?string
@@ -915,79 +872,4 @@ class IntegrationsRepository
] );
}
private function sendImageToShopproApi(
string $src,
int $remoteProductId,
string $remoteDomain,
string $apiKey,
string $alt,
int $position
): string
{
$src = trim( $src );
if ( $src === '' ) {
return '';
}
$localSourcePath = '..' . $src;
if ( !is_file( $localSourcePath ) ) {
return '';
}
$content = @file_get_contents( $localSourcePath );
if ( $content === false ) {
return '';
}
$remoteDomain = trim( $remoteDomain );
if ( $remoteDomain === '' ) {
return '';
}
if ( strpos( $remoteDomain, 'http://' ) !== 0 && strpos( $remoteDomain, 'https://' ) !== 0 ) {
$remoteDomain = 'https://' . $remoteDomain;
}
$remoteDomain = rtrim( $remoteDomain, '/' );
$url = $remoteDomain . '/api.php?endpoint=products&action=upload_image';
$payload = [
'id' => $remoteProductId,
'file_name' => basename( $src ),
'content_base64' => base64_encode( $content ),
'alt' => $alt,
'o' => $position,
];
$ch = curl_init( $url );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $payload, JSON_UNESCAPED_UNICODE ) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
] );
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false );
$response = curl_exec( $ch );
if ( curl_errno( $ch ) ) {
curl_close( $ch );
return '';
}
$httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
if ( $httpCode >= 400 || $response === false ) {
return '';
}
$responseData = json_decode( (string) $response, true );
if ( !is_array( $responseData ) || ( $responseData['status'] ?? '' ) !== 'ok' ) {
return '';
}
return (string)($responseData['data']['src'] ?? '');
}
}

View File

@@ -357,4 +357,34 @@ class ProducerRepository
return 0;
}
/**
* Znajdź producenta po nazwie lub utwórz nowego (dla API).
*
* @return array{id: int, created: bool}
*/
public function ensureProducerForApi(string $name): array
{
$name = trim($name);
if ($name === '') {
return ['id' => 0, 'created' => false];
}
$existing = $this->db->get('pp_shop_producer', 'id', ['name' => $name]);
if (!empty($existing)) {
return ['id' => (int)$existing, 'created' => false];
}
$this->db->insert('pp_shop_producer', [
'name' => $name,
'status' => 1,
'img' => null,
]);
$id = (int)$this->db->id();
if ($id <= 0) {
return ['id' => 0, 'created' => false];
}
return ['id' => $id, 'created' => true];
}
}

View File

@@ -657,6 +657,7 @@ class ProductRepository
'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null,
'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null,
'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null,
'producer_name' => $this->resolveProducerName($product['producer_id']),
'date_add' => $product['date_add'],
'date_modify' => $product['date_modify'],
];
@@ -682,6 +683,7 @@ class ProductRepository
'tab_name_2' => $lang['tab_name_2'],
'tab_description_2' => $lang['tab_description_2'],
'canonical' => $lang['canonical'],
'security_information' => $lang['security_information'] ?? null,
];
}
}
@@ -733,6 +735,19 @@ class ProductRepository
}
}
// Custom fields (Dodatkowe pola)
$customFields = $this->db->select('pp_shop_products_custom_fields', ['name', 'type', 'is_required'], ['id_product' => $id]);
$result['custom_fields'] = [];
if (is_array($customFields)) {
foreach ($customFields as $cf) {
$result['custom_fields'][] = [
'name' => $cf['name'],
'type' => !empty($cf['type']) ? $cf['type'] : 'text',
'is_required' => $cf['is_required'],
];
}
}
// Variants (only for parent products)
if (empty($product['parent_id'])) {
$result['variants'] = $this->findVariantsForApi($id);
@@ -1116,6 +1131,21 @@ class ProductRepository
return $result;
}
/**
* Zwraca nazwę producenta po ID (null jeśli brak).
*
* @param mixed $producerId
* @return string|null
*/
private function resolveProducerName($producerId): ?string
{
if (empty($producerId)) {
return null;
}
$name = $this->db->get('pp_shop_producer', 'name', ['id' => (int)$producerId]);
return ($name !== false && $name !== null) ? (string)$name : null;
}
/**
* Szczegóły produktu (admin) — zastępuje factory product_details().
*/
@@ -1301,7 +1331,10 @@ class ProductRepository
$this->saveImagesOrder( $productId, $d['gallery_order'] );
}
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
// Zapisz custom fields tylko gdy jawnie podane (partial update przez API może nie zawierać tego klucza)
if ( array_key_exists( 'custom_field_name', $d ) ) {
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
}
if ( !$isNew ) {
$this->cleanupDeletedFiles( $productId );
@@ -1615,6 +1648,7 @@ class ProductRepository
$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_custom_fields', [ 'id_product' => $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 ] );

View File

@@ -265,16 +265,6 @@ class IntegrationsController
exit;
}
public function shoppro_product_export(): void
{
$productId = (int) \Shared\Helpers\Helpers::get( 'product_id' );
$result = $this->repository->shopproExportProduct( $productId );
\Shared\Helpers\Helpers::alert( (string)($result['message'] ?? 'Wystapil blad podczas eksportu produktu.') );
header( 'Location: /admin/shop_product/view_list/' );
exit;
}
private function fetchApiloListWithFeedback( string $type, string $label ): void
{
$result = $this->repository->apiloFetchListResult( $type );

View File

@@ -106,6 +106,14 @@ class ProductArchiveController
'confirm_ok' => 'Przywroc',
'confirm_cancel' => 'Anuluj',
],
[
'label' => 'Usun trwale',
'url' => '/admin/product_archive/delete_permanent/product_id=' . $id,
'class' => 'btn btn-xs btn-danger',
'confirm' => 'UWAGA! Operacja nieodwracalna!' . "\n\n" . 'Produkt "' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" zostanie trwale usuniety razem ze wszystkimi zdjeciami i zalacznikami z serwera.' . "\n\n" . 'Czy na pewno chcesz usunac ten produkt?',
'confirm_ok' => 'Tak, usun trwale',
'confirm_cancel' => 'Anuluj',
],
],
];
}
@@ -162,4 +170,24 @@ class ProductArchiveController
header( 'Location: /admin/product_archive/list/' );
exit;
}
public function delete_permanent(): void
{
$productId = (int) \Shared\Helpers\Helpers::get( 'product_id' );
if ( $productId <= 0 ) {
\Shared\Helpers\Helpers::alert( 'Nieprawidłowe ID produktu.' );
header( 'Location: /admin/product_archive/list/' );
exit;
}
if ( $this->productRepository->delete( $productId ) ) {
\Shared\Helpers\Helpers::set_message( 'Produkt został trwale usunięty wraz ze zdjęciami i załącznikami.' );
} else {
\Shared\Helpers\Helpers::alert( 'Podczas usuwania produktu wystąpił błąd. Proszę spróbować ponownie.' );
}
header( 'Location: /admin/product_archive/list/' );
exit;
}
}

View File

@@ -140,14 +140,6 @@ class ShopProductController
}
}
if ( $shopproEnabled ) {
$row['_actions'][] = [
'label' => 'Eksportuj do shopPRO',
'url' => '/admin/integrations/shoppro_product_export/product_id=' . $id,
'class' => 'btn btn-xs btn-system',
'confirm' => 'Na pewno chcesz wyeksportowac ten produkt do shopPRO?',
];
}
$rows[] = $row;
}

View File

@@ -100,7 +100,8 @@ class ApiRouter
$transportRepo = new \Domain\Transport\TransportRepository($db);
$paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db);
$attrRepo = new \Domain\Attribute\AttributeRepository($db);
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo, $attrRepo);
$producerRepo = new \Domain\Producer\ProducerRepository($db);
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo, $attrRepo, $producerRepo);
},
];
}

View File

@@ -3,6 +3,7 @@ namespace api\Controllers;
use api\ApiRouter;
use Domain\Attribute\AttributeRepository;
use Domain\Producer\ProducerRepository;
use Domain\ShopStatus\ShopStatusRepository;
use Domain\Transport\TransportRepository;
use Domain\PaymentMethod\PaymentMethodRepository;
@@ -13,17 +14,20 @@ class DictionariesApiController
private $transportRepo;
private $paymentRepo;
private $attrRepo;
private $producerRepo;
public function __construct(
ShopStatusRepository $statusRepo,
TransportRepository $transportRepo,
PaymentMethodRepository $paymentRepo,
AttributeRepository $attrRepo
AttributeRepository $attrRepo,
ProducerRepository $producerRepo
) {
$this->statusRepo = $statusRepo;
$this->transportRepo = $transportRepo;
$this->paymentRepo = $paymentRepo;
$this->attrRepo = $attrRepo;
$this->producerRepo = $producerRepo;
}
public function statuses(): void
@@ -171,4 +175,34 @@ class DictionariesApiController
'created' => !empty($result['created']),
]);
}
public function ensure_producer(): void
{
if (!ApiRouter::requireMethod('POST')) {
return;
}
$body = ApiRouter::getJsonBody();
if (!is_array($body)) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400);
return;
}
$result = $this->producerRepo->ensureProducerForApi($name);
if ((int) ($result['id'] ?? 0) <= 0) {
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure producer', 500);
return;
}
ApiRouter::sendSuccess([
'id' => (int) ($result['id'] ?? 0),
'created' => !empty($result['created']),
]);
}
}

View File

@@ -338,7 +338,8 @@ class ProductsApiController
$safeName = 'image_' . md5((string)microtime(true)) . '.jpg';
}
$baseDir = '../upload/product_images/product_' . $productId;
// api.php działa z rootu projektu (nie z admin/), więc ścieżka bez ../
$baseDir = 'upload/product_images/product_' . $productId;
if (!is_dir($baseDir) && !mkdir($baseDir, 0775, true) && !is_dir($baseDir)) {
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create target directory', 500);
return;
@@ -497,6 +498,21 @@ class ProductsApiController
$d['products_related'] = $body['products_related'];
}
// Custom fields (Dodatkowe pola)
if (isset($body['custom_fields']) && is_array($body['custom_fields'])) {
$d['custom_field_name'] = [];
$d['custom_field_type'] = [];
$d['custom_field_required'] = [];
foreach ($body['custom_fields'] as $cf) {
if (!is_array($cf) || empty($cf['name'])) {
continue;
}
$d['custom_field_name'][] = (string)$cf['name'];
$d['custom_field_type'][] = !empty($cf['type']) ? (string)$cf['type'] : 'text';
$d['custom_field_required'][] = !empty($cf['is_required']) ? 1 : 0;
}
}
return $d;
}
}

View File

@@ -219,6 +219,7 @@ Odpowiedz:
"set_id": null,
"product_unit_id": 1,
"producer_id": 3,
"producer_name": "Nike",
"date_add": "2026-01-15 10:00:00",
"date_modify": "2026-02-19 12:00:00",
"languages": {
@@ -237,7 +238,8 @@ Odpowiedz:
"tab_description_1": null,
"tab_name_2": null,
"tab_description_2": null,
"canonical": null
"canonical": null,
"security_information": null
}
},
"images": [
@@ -253,6 +255,9 @@ Odpowiedz:
"value_names": {"pl": "Czerwony", "en": "Red"}
}
],
"custom_fields": [
{"name": "Napis na koszulce", "type": "text", "is_required": 1}
],
"variants": [
{
"id": 101,
@@ -297,11 +302,15 @@ Content-Type: application/json
}
},
"categories": [1, 5],
"products_related": [10, 20]
"products_related": [10, 20],
"custom_fields": [
{"name": "Napis na koszulce", "type": "text", "is_required": 1}
]
}
```
Wymagane: `languages` (min. 1 jezyk z `name`) oraz `price_brutto`.
`custom_fields` — opcjonalne; kazdy element wymaga `name`, `type` (domyslnie `text`), `is_required` (0/1).
Odpowiedz (HTTP 201):
```json
@@ -468,6 +477,31 @@ GET api.php?endpoint=dictionaries&action=attributes
Zwraca aktywne atrybuty z wartosciami i wielojezycznymi nazwami.
#### Znajdz lub utworz producenta
```
POST api.php?endpoint=dictionaries&action=ensure_producer
Content-Type: application/json
{
"name": "Nike"
}
```
Zwraca istniejacego producenta po nazwie lub tworzy nowego. Uzyc przed tworzeniem produktu, jesli producent moze nie istniec.
Odpowiedz:
```json
{
"status": "ok",
"data": {
"id": 5,
"created": false
}
}
```
`created: true` gdy producent zostal nowo dodany, `false` gdy juz istnial.
Odpowiedz:
```json
{
@@ -514,4 +548,4 @@ UPDATE pp_settings SET value = 'twoj-klucz-api' WHERE param = 'api_key';
- Kontrolery: `autoload/api/Controllers/`
- `OrdersApiController` — zamowienia (5 akcji)
- `ProductsApiController` — produkty (8 akcji: list, get, create, update, variants, create_variant, update_variant, delete_variant)
- `DictionariesApiController` — slowniki (4 akcje: statuses, transports, payment_methods, attributes)
- `DictionariesApiController` — slowniki (5 akcji: statuses, transports, payment_methods, attributes, ensure_producer)

View File

@@ -4,6 +4,17 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
## ver. 0.323 (2026-02-24) - Import zdjęć, trwałe usuwanie, fix API upload
- **FIX**: `IntegrationsRepository::shopproImportProduct()` — kompletny refactor importu zdjęć: walidacja HTTP response, curl timeouty, bezpieczna budowa URL, szczegółowy log do `logs/shoppro-import-debug.log` i `error_log`, czytelny komunikat z wynikiem
- **FIX**: `ProductRepository::saveProduct()``saveCustomFields()` wywoływane tylko gdy klucz `custom_field_name` istnieje w danych (partial update przez API nie czyści custom fields)
- **FIX**: `ProductRepository::delete()` — usuwanie rekordów z `pp_shop_products_custom_fields` przy kasowaniu produktu
- **FIX**: `ProductsApiController::upload_image()` — poprawka ścieżki uploadu (`upload/` zamiast `../upload/` — api.php działa z rootu projektu)
- **NEW**: `ProductArchiveController::delete_permanent()` — trwałe usunięcie produktu z archiwum (wraz ze zdjęciami i załącznikami)
- **NEW**: Przycisk "Usuń trwale" w liście produktów archiwalnych z potwierdzeniem
---
## ver. 0.318 (2026-02-24) - ShopPRO export produktów + API endpoints
- **NEW**: `IntegrationsRepository::shopproExportProduct()` — eksport produktu do zdalnej instancji shopPRO: pola główne, tłumaczenia, custom fields, zdjęcia przez API (base64)

View File

@@ -46,6 +46,17 @@ Zdjęcia produktów.
| src | Ścieżka do pliku |
| alt | Tekst alternatywny |
## pp_shop_products_custom_fields
Dodatkowe pola produktów (custom fields).
| Kolumna | Opis |
|---------|------|
| id_additional_field | PK |
| id_product | FK do pp_shop_products |
| name | Nazwa pola |
| type | Typ pola (VARCHAR 30) |
| is_required | Czy wymagane (0/1) |
## pp_shop_products_categories
Przypisanie produktów do kategorii.

View File

@@ -229,7 +229,7 @@ class IntegrationsRepositoryTest extends TestCase
'linkProduct', 'unlinkProduct',
'apiloAuthorize', 'apiloGetAccessToken', 'apiloKeepalive', 'apiloIntegrationStatus',
'apiloFetchList', 'apiloFetchListResult', 'apiloProductSearch', 'apiloCreateProduct',
'getProductSku', 'shopproImportProduct', 'shopproExportProduct',
'getProductSku', 'shopproImportProduct',
];
foreach ($expectedMethods as $method) {

View File

@@ -118,7 +118,6 @@ class IntegrationsControllerTest extends TestCase
'shoppro_settings',
'shoppro_settings_save',
'shoppro_product_import',
'shoppro_product_export',
];
foreach ($methods as $method) {
@@ -158,7 +157,6 @@ class IntegrationsControllerTest extends TestCase
'apilo_product_select_delete',
'shoppro_settings_save',
'shoppro_product_import',
'shoppro_product_export',
];
foreach ($voidMethods as $method) {

View File

@@ -4,6 +4,7 @@ namespace Tests\Unit\api\Controllers;
use PHPUnit\Framework\TestCase;
use api\Controllers\DictionariesApiController;
use Domain\Attribute\AttributeRepository;
use Domain\Producer\ProducerRepository;
use Domain\ShopStatus\ShopStatusRepository;
use Domain\Transport\TransportRepository;
use Domain\PaymentMethod\PaymentMethodRepository;
@@ -14,6 +15,7 @@ class DictionariesApiControllerTest extends TestCase
private $mockTransportRepo;
private $mockPaymentRepo;
private $mockAttrRepo;
private $mockProducerRepo;
private $controller;
protected function setUp(): void
@@ -22,12 +24,14 @@ class DictionariesApiControllerTest extends TestCase
$this->mockTransportRepo = $this->createMock(TransportRepository::class);
$this->mockPaymentRepo = $this->createMock(PaymentMethodRepository::class);
$this->mockAttrRepo = $this->createMock(AttributeRepository::class);
$this->mockProducerRepo = $this->createMock(ProducerRepository::class);
$this->controller = new DictionariesApiController(
$this->mockStatusRepo,
$this->mockTransportRepo,
$this->mockPaymentRepo,
$this->mockAttrRepo
$this->mockAttrRepo,
$this->mockProducerRepo
);
$_SERVER['REQUEST_METHOD'] = 'GET';

BIN
updates/0.30/ver_0.318.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,30 @@
{
"changelog": "NEW - shopPRO export produktów + API endpoints (ensure_attribute, ensure_attribute_value, upload_image)",
"version": "0.318",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"admin/templates/integrations/shoppro-settings.php",
"autoload/Domain/Attribute/AttributeRepository.php",
"autoload/Domain/Integrations/IntegrationsRepository.php",
"autoload/Domain/Product/ProductRepository.php",
"autoload/admin/Controllers/IntegrationsController.php",
"autoload/admin/Controllers/ShopProductController.php",
"autoload/api/Controllers/DictionariesApiController.php",
"autoload/api/Controllers/ProductsApiController.php"
]
},
"checksum_zip": "sha256:6a7eba1b390db94ccda210a5f2cbcd33f17f43d9f34031c4d0793d224df5d541",
"sql": [
],
"date": "2026-02-24",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.319.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,26 @@
{
"changelog": "FIX - usunięcie shopPRO eksportu produktów; API produktu: dodano custom_fields i security_information",
"version": "0.319",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/Integrations/IntegrationsRepository.php",
"autoload/Domain/Product/ProductRepository.php",
"autoload/admin/Controllers/IntegrationsController.php",
"autoload/admin/Controllers/ShopProductController.php"
]
},
"checksum_zip": "sha256:99e07eb85aeca1c96607e95c90408bfbc166d97c0b999cc9eb033c6a4f208b97",
"sql": [
],
"date": "2026-02-24",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.320.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,26 @@
{
"changelog": "NEW - API: endpoint ensure_producer (znajdź lub utwórz producenta); GET product zwraca producer_name",
"version": "0.320",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/Producer/ProducerRepository.php",
"autoload/Domain/Product/ProductRepository.php",
"autoload/api/ApiRouter.php",
"autoload/api/Controllers/DictionariesApiController.php"
]
},
"checksum_zip": "sha256:eb38b6f260768c25d331de60098eba647a897972c211b37b39314c8a3f954bf3",
"sql": [
],
"date": "2026-02-24",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.321.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,23 @@
{
"changelog": "NEW - API: obsługa custom_fields w create/update produktu",
"version": "0.321",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/api/Controllers/ProductsApiController.php"
]
},
"checksum_zip": "sha256:a04ac9975618bc3b21d80a8e449f98e5bc825ce49a37142e1e621a2ef34a19f1",
"sql": [
],
"date": "2026-02-24",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.322.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,24 @@
{
"changelog": "FIX - custom_fields: jawne mapowanie kluczy w ProductRepository, spójne !empty w ProductsApiController",
"version": "0.322",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/Product/ProductRepository.php",
"autoload/api/Controllers/ProductsApiController.php"
]
},
"checksum_zip": "sha256:7c6e04cc393fdcd94e6fc9d2ea7a85f9c078e4beb9075d490be90675e4f6eae7",
"sql": [
],
"date": "2026-02-24",
"directories_deleted": [
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
<?
$current_ver = 317;
$current_ver = 323;
for ($i = 1; $i <= $current_ver; $i++)
{