feat: Add CronJob functionality and integrate with existing services

- Implemented CronJobProcessor for managing scheduled jobs and processing job queues.
- Created CronJobRepository for database interactions related to cron jobs.
- Defined CronJobType for job types, statuses, and backoff calculations.
- Added ApiloLogger for logging actions related to API interactions.
- Enhanced UpdateController to check for updates and display update logs.
- Updated FormAction to include a preview action for forms.
- Modified ApiRouter to handle new dependencies for OrderAdminService and ProductsApiController.
- Extended DictionariesApiController to manage attributes and producers.
- Enhanced ProductsApiController with variant management and image upload functionality.
- Updated ShopBasketController and ShopProductController to sort attributes and handle custom fields.
- Added configuration for cron jobs in config.php.
- Initialized apilo-sync-queue.json for managing sync tasks.
This commit is contained in:
2026-02-27 14:54:05 +01:00
parent 3ecbe628dc
commit 31fd0442b2
33 changed files with 2714 additions and 200 deletions

View File

@@ -48,7 +48,7 @@ class AttributeRepository
FROM pp_shop_attributes AS sa
WHERE {$whereSql}
";
$stmtCount = $this->db->query($sqlCount, $params);
$stmtCount = $this->db->query($sqlCount, $whereData['params']);
$countRows = $stmtCount ? $stmtCount->fetchAll() : [];
$total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0;
@@ -534,6 +534,216 @@ class AttributeRepository
return $attributes;
}
/**
* Zwraca aktywne atrybuty z wartościami i wielojęzycznymi nazwami dla REST API.
*
* @return array<int, array<string, mixed>>
*/
public function listForApi(): array
{
// 1. Get all active attribute IDs (1 query)
$rows = $this->db->select('pp_shop_attributes', ['id', 'type', 'status'], [
'status' => 1,
'ORDER' => ['o' => 'ASC'],
]);
if (!is_array($rows) || empty($rows)) {
return [];
}
$attrIds = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id > 0) {
$attrIds[] = $id;
}
}
if (empty($attrIds)) {
return [];
}
// 2. Batch load ALL attribute translations (1 query)
$allAttrTranslations = $this->db->select(
'pp_shop_attributes_langs',
['attribute_id', 'lang_id', 'name'],
['attribute_id' => $attrIds]
);
$attrNamesMap = [];
if (is_array($allAttrTranslations)) {
foreach ($allAttrTranslations as $t) {
$aId = (int)($t['attribute_id'] ?? 0);
$langId = (string)($t['lang_id'] ?? '');
if ($aId > 0 && $langId !== '') {
$attrNamesMap[$aId][$langId] = (string)($t['name'] ?? '');
}
}
}
// 3. Batch load ALL values for those attribute IDs (1 query)
$allValueRows = $this->db->select(
'pp_shop_attributes_values',
['id', 'attribute_id', 'is_default', 'impact_on_the_price'],
[
'attribute_id' => $attrIds,
'ORDER' => ['id' => 'ASC'],
]
);
$valuesByAttr = [];
$allValueIds = [];
if (is_array($allValueRows)) {
foreach ($allValueRows as $vRow) {
$valueId = (int)($vRow['id'] ?? 0);
$attrId = (int)($vRow['attribute_id'] ?? 0);
if ($valueId > 0 && $attrId > 0) {
$valuesByAttr[$attrId][] = $vRow;
$allValueIds[] = $valueId;
}
}
}
// 4. Batch load ALL value translations (1 query)
$valueNamesMap = [];
if (!empty($allValueIds)) {
$allValueTranslations = $this->db->select(
'pp_shop_attributes_values_langs',
['value_id', 'lang_id', 'name'],
['value_id' => $allValueIds]
);
if (is_array($allValueTranslations)) {
foreach ($allValueTranslations as $vt) {
$vId = (int)($vt['value_id'] ?? 0);
$langId = (string)($vt['lang_id'] ?? '');
if ($vId > 0 && $langId !== '') {
$valueNamesMap[$vId][$langId] = (string)($vt['name'] ?? '');
}
}
}
}
// 5. Assemble result in-memory
$result = [];
foreach ($rows as $row) {
$attributeId = (int)($row['id'] ?? 0);
if ($attributeId <= 0) {
continue;
}
$names = isset($attrNamesMap[$attributeId]) ? $attrNamesMap[$attributeId] : [];
$values = [];
if (isset($valuesByAttr[$attributeId])) {
foreach ($valuesByAttr[$attributeId] as $vRow) {
$valueId = (int)$vRow['id'];
$impact = $vRow['impact_on_the_price'];
$values[] = [
'id' => $valueId,
'names' => isset($valueNamesMap[$valueId]) ? $valueNamesMap[$valueId] : [],
'is_default' => (int)($vRow['is_default'] ?? 0),
'impact_on_the_price' => ($impact !== null && $impact !== '') ? (float)$impact : null,
];
}
}
$result[] = [
'id' => $attributeId,
'type' => (int)($row['type'] ?? 0),
'status' => (int)($row['status'] ?? 0),
'names' => $names,
'values' => $values,
];
}
return $result;
}
/**
* Find existing attribute by name/type or create a new one for API integration.
*
* @return array{id:int,created:bool}|null
*/
public function ensureAttributeForApi(string $name, int $type = 0, string $langId = 'pl'): ?array
{
$normalizedName = trim($name);
$normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl';
$normalizedType = $this->toTypeValue($type);
if ($normalizedName === '') {
return null;
}
$existingId = $this->findAttributeIdByNameAndType($normalizedName, $normalizedType);
if ($existingId > 0) {
return ['id' => $existingId, 'created' => false];
}
$this->db->insert('pp_shop_attributes', [
'status' => 1,
'type' => $normalizedType,
'o' => $this->nextOrder(),
]);
$attributeId = (int) $this->db->id();
if ($attributeId <= 0) {
return null;
}
$this->db->insert('pp_shop_attributes_langs', [
'attribute_id' => $attributeId,
'lang_id' => $normalizedLangId,
'name' => $normalizedName,
]);
$this->clearTempAndCache();
$this->clearFrontCache($attributeId, 'frontAttributeDetails');
return ['id' => $attributeId, 'created' => true];
}
/**
* Find existing value by name within attribute or create a new one for API integration.
*
* @return array{id:int,created:bool}|null
*/
public function ensureAttributeValueForApi(int $attributeId, string $name, string $langId = 'pl'): ?array
{
$normalizedName = trim($name);
$normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl';
$attributeId = max(0, $attributeId);
if ($attributeId <= 0 || $normalizedName === '') {
return null;
}
$attributeExists = (int) $this->db->count('pp_shop_attributes', ['id' => $attributeId]) > 0;
if (!$attributeExists) {
return null;
}
$existingId = $this->findAttributeValueIdByName($attributeId, $normalizedName);
if ($existingId > 0) {
return ['id' => $existingId, 'created' => false];
}
$this->db->insert('pp_shop_attributes_values', [
'attribute_id' => $attributeId,
'impact_on_the_price' => null,
'is_default' => 0,
]);
$valueId = (int) $this->db->id();
if ($valueId <= 0) {
return null;
}
$this->db->insert('pp_shop_attributes_values_langs', [
'value_id' => $valueId,
'lang_id' => $normalizedLangId,
'name' => $normalizedName,
'value' => null,
]);
$this->clearTempAndCache();
$this->clearFrontCache($valueId, 'frontValueDetails');
return ['id' => $valueId, 'created' => true];
}
/**
* @return array{sql: string, params: array<string, mixed>}
*/
@@ -851,6 +1061,52 @@ class AttributeRepository
return $this->defaultLangId;
}
private function findAttributeIdByNameAndType(string $name, int $type): int
{
$statement = $this->db->query(
'SELECT sa.id
FROM pp_shop_attributes sa
INNER JOIN pp_shop_attributes_langs sal ON sal.attribute_id = sa.id
WHERE sa.type = :type
AND LOWER(TRIM(sal.name)) = LOWER(TRIM(:name))
ORDER BY sa.id ASC
LIMIT 1',
[
':type' => $type,
':name' => $name,
]
);
if (!$statement) {
return 0;
}
$id = $statement->fetchColumn();
return $id === false ? 0 : (int) $id;
}
private function findAttributeValueIdByName(int $attributeId, string $name): int
{
$statement = $this->db->query(
'SELECT sav.id
FROM pp_shop_attributes_values sav
INNER JOIN pp_shop_attributes_values_langs savl ON savl.value_id = sav.id
WHERE sav.attribute_id = :attribute_id
AND LOWER(TRIM(savl.name)) = LOWER(TRIM(:name))
ORDER BY sav.id ASC
LIMIT 1',
[
':attribute_id' => $attributeId,
':name' => $name,
]
);
if (!$statement) {
return 0;
}
$id = $statement->fetchColumn();
return $id === false ? 0 : (int) $id;
}
// ── Frontend methods ──────────────────────────────────────────
public function frontAttributeDetails(int $attributeId, string $langId): array

View File

@@ -94,8 +94,13 @@ class BasketCalculator
if ( isset( $val['parent_id'] ) and (int)$val['parent_id'] and isset( $val['product-id'] ) )
$permutation = $productRepo->getProductPermutationHash( (int)$val['product-id'] );
if ( !$permutation and isset( $val['attributes'] ) and is_array( $val['attributes'] ) and count( $val['attributes'] ) )
$permutation = implode( '|', $val['attributes'] );
if ( !$permutation and isset( $val['attributes'] ) and is_array( $val['attributes'] ) and count( $val['attributes'] ) ) {
$attrs = $val['attributes'];
usort( $attrs, function ( $a, $b ) {
return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0];
} );
$permutation = implode( '|', $attrs );
}
$quantity_options = $productRepo->getProductPermutationQuantityOptions(
$val['parent_id'] ? $val['parent_id'] : $val['product-id'],

View File

@@ -0,0 +1,140 @@
<?php
namespace Domain\CronJob;
class CronJobProcessor
{
/** @var CronJobRepository */
private $cronRepo;
/** @var array<string, callable> */
private $handlers = [];
/**
* @param CronJobRepository $cronRepo
*/
public function __construct(CronJobRepository $cronRepo)
{
$this->cronRepo = $cronRepo;
}
/**
* Zarejestruj handler dla typu zadania
*
* @param string $jobType
* @param callable $handler fn($payload): bool|array — true/array = success, false/exception = fail
*/
public function registerHandler($jobType, callable $handler)
{
$this->handlers[$jobType] = $handler;
}
/**
* Utwórz zadania z harmonogramów, których next_run_at <= NOW
*
* @return int Liczba utworzonych zadań
*/
public function createScheduledJobs()
{
$schedules = $this->cronRepo->getDueSchedules();
$created = 0;
foreach ($schedules as $schedule) {
$jobType = $schedule['job_type'];
// Nie twórz duplikatów
if ($this->cronRepo->hasPendingJob($jobType)) {
// Mimo duplikatu, przesuń next_run_at żeby nie sprawdzać co sekundę
$this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']);
continue;
}
$payload = null;
if (!empty($schedule['payload'])) {
$payload = json_decode($schedule['payload'], true);
}
$this->cronRepo->enqueue(
$jobType,
$payload,
(int) $schedule['priority'],
(int) $schedule['max_attempts']
);
$this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']);
$created++;
}
return $created;
}
/**
* Przetwórz kolejkę zadań
*
* @param int $limit
* @return array Statystyki: ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int]
*/
public function processQueue($limit = 10)
{
$stats = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0];
$jobs = $this->cronRepo->fetchNext($limit);
foreach ($jobs as $job) {
$jobType = $job['job_type'];
$jobId = (int) $job['id'];
$stats['processed']++;
if (!isset($this->handlers[$jobType])) {
$this->cronRepo->markFailed($jobId, 'No handler registered for job type: ' . $jobType, (int) $job['attempts']);
$stats['skipped']++;
continue;
}
try {
$result = call_user_func($this->handlers[$jobType], $job['payload']);
if ($result === false) {
$this->cronRepo->markFailed($jobId, 'Handler returned false', (int) $job['attempts']);
$stats['failed']++;
} else {
$resultData = is_array($result) ? $result : null;
$this->cronRepo->markCompleted($jobId, $resultData);
$stats['succeeded']++;
}
} catch (\Exception $e) {
$this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']);
$stats['failed']++;
} catch (\Throwable $e) {
$this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']);
$stats['failed']++;
}
}
return $stats;
}
/**
* Główna metoda: utwórz scheduled jobs + przetwórz kolejkę
*
* @param int $limit
* @return array ['scheduled' => int, 'processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int]
*/
public function run($limit = 20)
{
// Odzyskaj stuck jobs
$this->cronRepo->recoverStuck(30);
// Utwórz zadania z harmonogramów
$scheduled = $this->createScheduledJobs();
// Przetwórz kolejkę
$stats = $this->processQueue($limit);
$stats['scheduled'] = $scheduled;
// Cleanup starych zadań (raz na uruchomienie)
$this->cronRepo->cleanup(30);
return $stats;
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace Domain\CronJob;
class CronJobRepository
{
/** @var \medoo */
private $db;
/**
* @param \medoo $db
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Dodaj zadanie do kolejki
*
* @param string $jobType
* @param array|null $payload
* @param int $priority
* @param int $maxAttempts
* @param string|null $scheduledAt
* @return int|null ID nowego zadania
*/
public function enqueue($jobType, $payload = null, $priority = CronJobType::PRIORITY_NORMAL, $maxAttempts = 10, $scheduledAt = null)
{
$data = [
'job_type' => $jobType,
'status' => CronJobType::STATUS_PENDING,
'priority' => $priority,
'max_attempts' => $maxAttempts,
'scheduled_at' => $scheduledAt ? $scheduledAt : date('Y-m-d H:i:s'),
];
if ($payload !== null) {
$data['payload'] = json_encode($payload);
}
$this->db->insert('pp_cron_jobs', $data);
$id = $this->db->id();
return $id ? (int) $id : null;
}
/**
* Atomowe pobranie następnych zadań do przetworzenia.
*
* Uwaga: SELECT + UPDATE nie jest w pełni atomowe bez transakcji.
* Po UPDATE re-SELECT potwierdza, które joby zostały faktycznie przejęte
* (chroni przed race condition przy wielu workerach).
*
* @param int $limit
* @return array
*/
public function fetchNext($limit = 5)
{
$now = date('Y-m-d H:i:s');
$jobs = $this->db->select('pp_cron_jobs', '*', [
'status' => CronJobType::STATUS_PENDING,
'scheduled_at[<=]' => $now,
'ORDER' => ['priority' => 'ASC', 'scheduled_at' => 'ASC'],
'LIMIT' => $limit,
]);
if (empty($jobs)) {
return [];
}
$ids = array_column($jobs, 'id');
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PROCESSING,
'started_at' => $now,
'attempts[+]' => 1,
], [
'id' => $ids,
'status' => CronJobType::STATUS_PENDING,
]);
// Re-SELECT: potwierdź, które joby zostały faktycznie przejęte
$claimed = $this->db->select('pp_cron_jobs', '*', [
'id' => $ids,
'status' => CronJobType::STATUS_PROCESSING,
'started_at' => $now,
]);
if (empty($claimed)) {
return [];
}
foreach ($claimed as &$job) {
if ($job['payload'] !== null) {
$job['payload'] = json_decode($job['payload'], true);
}
}
return $claimed;
}
/**
* Oznacz zadanie jako zakończone
*
* @param int $jobId
* @param mixed $result
*/
public function markCompleted($jobId, $result = null)
{
$data = [
'status' => CronJobType::STATUS_COMPLETED,
'completed_at' => date('Y-m-d H:i:s'),
];
if ($result !== null) {
$data['result'] = json_encode($result);
}
$this->db->update('pp_cron_jobs', $data, ['id' => $jobId]);
}
/**
* Oznacz zadanie jako nieudane z backoffem
*
* @param int $jobId
* @param string $error
* @param int $attempt Numer próby (do obliczenia backoffu)
*/
public function markFailed($jobId, $error, $attempt = 1)
{
$job = $this->db->get('pp_cron_jobs', ['max_attempts', 'attempts'], ['id' => $jobId]);
$attempts = $job ? (int) $job['attempts'] : $attempt;
$maxAttempts = $job ? (int) $job['max_attempts'] : 10;
if ($attempts >= $maxAttempts) {
// Przekroczono limit prób — trwale failed
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_FAILED,
'last_error' => mb_substr($error, 0, 500),
'completed_at' => date('Y-m-d H:i:s'),
], ['id' => $jobId]);
} else {
// Wróć do pending z backoffem
$backoff = CronJobType::calculateBackoff($attempts);
$nextRun = date('Y-m-d H:i:s', time() + $backoff);
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'last_error' => mb_substr($error, 0, 500),
'scheduled_at' => $nextRun,
], ['id' => $jobId]);
}
}
/**
* Sprawdź czy istnieje pending job danego typu z opcjonalnym payload match
*
* @param string $jobType
* @param array|null $payloadMatch
* @return bool
*/
public function hasPendingJob($jobType, $payloadMatch = null)
{
$where = [
'job_type' => $jobType,
'status' => [CronJobType::STATUS_PENDING, CronJobType::STATUS_PROCESSING],
];
if ($payloadMatch !== null) {
$where['payload'] = json_encode($payloadMatch);
}
$count = $this->db->count('pp_cron_jobs', $where);
return $count > 0;
}
/**
* Wyczyść stare zakończone zadania
*
* @param int $olderThanDays
*/
public function cleanup($olderThanDays = 30)
{
$cutoff = date('Y-m-d H:i:s', time() - ($olderThanDays * 86400));
$this->db->delete('pp_cron_jobs', [
'status' => [CronJobType::STATUS_COMPLETED, CronJobType::STATUS_FAILED, CronJobType::STATUS_CANCELLED],
'updated_at[<]' => $cutoff,
]);
}
/**
* Odzyskaj zablokowane zadania (stuck w processing)
*
* @param int $olderThanMinutes
*/
public function recoverStuck($olderThanMinutes = 30)
{
$cutoff = date('Y-m-d H:i:s', time() - ($olderThanMinutes * 60));
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'started_at' => null,
], [
'status' => CronJobType::STATUS_PROCESSING,
'started_at[<]' => $cutoff,
]);
}
/**
* Pobierz harmonogramy gotowe do uruchomienia
*
* @return array
*/
public function getDueSchedules()
{
$now = date('Y-m-d H:i:s');
return $this->db->select('pp_cron_schedules', '*', [
'enabled' => 1,
'OR' => [
'next_run_at' => null,
'next_run_at[<=]' => $now,
],
'ORDER' => ['priority' => 'ASC'],
]);
}
/**
* Aktualizuj harmonogram po uruchomieniu
*
* @param int $scheduleId
* @param int $intervalSeconds
*/
public function touchSchedule($scheduleId, $intervalSeconds)
{
$now = date('Y-m-d H:i:s');
$nextRun = date('Y-m-d H:i:s', time() + $intervalSeconds);
$this->db->update('pp_cron_schedules', [
'last_run_at' => $now,
'next_run_at' => $nextRun,
], ['id' => $scheduleId]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Domain\CronJob;
class CronJobType
{
// Job types
const APILO_TOKEN_KEEPALIVE = 'apilo_token_keepalive';
const APILO_SEND_ORDER = 'apilo_send_order';
const APILO_SYNC_PAYMENT = 'apilo_sync_payment';
const APILO_SYNC_STATUS = 'apilo_sync_status';
const APILO_PRODUCT_SYNC = 'apilo_product_sync';
const APILO_PRICELIST_SYNC = 'apilo_pricelist_sync';
const APILO_STATUS_POLL = 'apilo_status_poll';
const PRICE_HISTORY = 'price_history';
const ORDER_ANALYSIS = 'order_analysis';
const TRUSTMATE_INVITATION = 'trustmate_invitation';
const GOOGLE_XML_FEED = 'google_xml_feed';
// Priorities (lower = more important)
const PRIORITY_CRITICAL = 10;
const PRIORITY_SEND_ORDER = 40; // apilo_send_order musi być PRZED sync payment/status
const PRIORITY_HIGH = 50;
const PRIORITY_NORMAL = 100;
const PRIORITY_LOW = 200;
// Statuses
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
const STATUS_CANCELLED = 'cancelled';
// Backoff
const BASE_BACKOFF_SECONDS = 60;
const MAX_BACKOFF_SECONDS = 3600;
/**
* @return string[]
*/
public static function allTypes()
{
return [
self::APILO_TOKEN_KEEPALIVE,
self::APILO_SEND_ORDER,
self::APILO_SYNC_PAYMENT,
self::APILO_SYNC_STATUS,
self::APILO_PRODUCT_SYNC,
self::APILO_PRICELIST_SYNC,
self::APILO_STATUS_POLL,
self::PRICE_HISTORY,
self::ORDER_ANALYSIS,
self::TRUSTMATE_INVITATION,
self::GOOGLE_XML_FEED,
];
}
/**
* @return string[]
*/
public static function allStatuses()
{
return [
self::STATUS_PENDING,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCELLED,
];
}
/**
* @param int $attempt
* @return int
*/
public static function calculateBackoff($attempt)
{
$backoff = self::BASE_BACKOFF_SECONDS * pow(2, $attempt - 1);
return min($backoff, self::MAX_BACKOFF_SECONDS);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Domain\Integrations;
class ApiloLogger
{
/**
* @param \medoo $db
* @param string $action np. 'send_order', 'payment_sync', 'status_sync', 'status_poll'
* @param int|null $orderId
* @param string $message
* @param mixed $context dane do zapisania jako JSON (request/response)
*/
public static function log($db, string $action, ?int $orderId, string $message, $context = null): void
{
$contextJson = null;
if ($context !== null) {
$contextJson = is_string($context)
? $context
: json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
$db->insert('pp_log', [
'action' => $action,
'order_id' => $orderId,
'message' => $message,
'context' => $contextJson,
'date' => date('Y-m-d H:i:s'),
]);
}
}

View File

@@ -56,6 +56,63 @@ class IntegrationsRepository
return true;
}
// ── Logs ────────────────────────────────────────────────────
/**
* Pobiera logi z tabeli pp_log z paginacją, sortowaniem i filtrowaniem.
*
* @return array{items:array, total:int}
*/
public function getLogs( array $filters, string $sortColumn, string $sortDir, int $page, int $perPage ): array
{
$where = [];
if ( !empty( $filters['log_action'] ) ) {
$where['action[~]'] = '%' . $filters['log_action'] . '%';
}
if ( !empty( $filters['message'] ) ) {
$where['message[~]'] = '%' . $filters['message'] . '%';
}
if ( !empty( $filters['order_id'] ) ) {
$where['order_id'] = (int) $filters['order_id'];
}
$total = $this->db->count( 'pp_log', $where );
$where['ORDER'] = [ $sortColumn => $sortDir ];
$where['LIMIT'] = [ ( $page - 1 ) * $perPage, $perPage ];
$items = $this->db->select( 'pp_log', '*', $where );
if ( !is_array( $items ) ) {
$items = [];
}
return [
'items' => $items,
'total' => (int) $total,
];
}
/**
* Usuwa wpis logu po ID.
*/
public function deleteLog( int $id ): bool
{
$this->db->delete( 'pp_log', [ 'id' => $id ] );
return true;
}
/**
* Czyści wszystkie logi z tabeli pp_log.
*/
public function clearLogs(): bool
{
$this->db->delete( 'pp_log', [] );
return true;
}
// ── Product linking (Apilo) ─────────────────────────────────
public function linkProduct( int $productId, $externalId, $externalName ): bool
@@ -611,15 +668,12 @@ class IntegrationsRepository
public function shopproImportProduct( int $productId ): array
{
$settings = $this->getSettings( 'shoppro' );
$missingSetting = $this->missingShopproSetting( $settings, [ 'domain', 'db_name', 'db_host', 'db_user' ] );
if ( $missingSetting !== null ) {
return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ];
}
$mdb2 = new \medoo( [
'database_type' => 'mysql',
'database_name' => $settings['db_name'],
'server' => $settings['db_host'],
'username' => $settings['db_user'],
'password' => $settings['db_password'],
'charset' => 'utf8'
] );
$mdb2 = $this->shopproDb( $settings );
$product = $mdb2->get( 'pp_shop_products', '*', [ 'id' => $productId ] );
if ( !$product )
@@ -643,6 +697,7 @@ class IntegrationsRepository
'additional_message_text' => $product['additional_message_text'],
'additional_message_required'=> $product['additional_message_required'],
'weight' => $product['weight'],
'producer_id' => $product['producer_id'] ?? null,
] );
$newProductId = $this->db->id();
@@ -672,41 +727,149 @@ class IntegrationsRepository
'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'],
'canonical' => $lang['canonical'],
'xml_name' => $lang['xml_name'],
'security_information' => $lang['security_information'] ?? null,
] );
}
}
// Import custom fields
$customFields = $mdb2->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] );
if ( is_array( $customFields ) ) {
foreach ( $customFields as $field ) {
$this->db->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,
] );
}
}
// 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,
'src' => '/upload/product_images/product_' . $newProductId . '/' . $imageName,
'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 ) );
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 );
}
// 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ł zaimportowany. ' . $imgSummary ];
}
private function missingShopproSetting( array $settings, array $requiredKeys ): ?string
{
foreach ( $requiredKeys as $requiredKey ) {
if ( trim( (string)($settings[$requiredKey] ?? '') ) === '' ) {
return $requiredKey;
}
}
return null;
}
private function shopproDb( array $settings ): \medoo
{
return new \medoo( [
'database_type' => 'mysql',
'database_name' => $settings['db_name'],
'server' => $settings['db_host'],
'username' => $settings['db_user'],
'password' => $settings['db_password'] ?? '',
'charset' => 'utf8'
] );
}
}

View File

@@ -7,17 +7,21 @@ class OrderAdminService
private $productRepo;
private $settingsRepo;
private $transportRepo;
/** @var \Domain\CronJob\CronJobRepository|null */
private $cronJobRepo;
public function __construct(
OrderRepository $orders,
$productRepo = null,
$settingsRepo = null,
$transportRepo = null
$transportRepo = null,
$cronJobRepo = null
) {
$this->orders = $orders;
$this->productRepo = $productRepo;
$this->settingsRepo = $settingsRepo;
$this->transportRepo = $transportRepo;
$this->cronJobRepo = $cronJobRepo;
}
public function details(int $orderId): array
@@ -30,6 +34,14 @@ class OrderAdminService
return $this->orders->orderStatuses();
}
/**
* @return array{names: array<int, string>, colors: array<int, string>}
*/
public function statusData(): array
{
return $this->orders->orderStatusData();
}
/**
* @return array{items: array<int, array<string, mixed>>, total: int}
*/
@@ -385,17 +397,38 @@ class OrderAdminService
global $mdb;
if ($orderId <= 0) {
\Domain\Integrations\ApiloLogger::log(
$mdb,
'resend_order',
$orderId,
'Nieprawidlowe ID zamowienia (orderId <= 0)',
['order_id' => $orderId]
);
return false;
}
$order = $this->orders->findForAdmin($orderId);
if (empty($order) || empty($order['apilo_order_id'])) {
\Domain\Integrations\ApiloLogger::log(
$mdb,
'resend_order',
$orderId,
'Brak zamowienia lub brak apilo_order_id',
['order_found' => !empty($order), 'apilo_order_id' => $order['apilo_order_id'] ?? null]
);
return false;
}
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
$accessToken = $integrationsRepository -> apiloGetAccessToken();
if (!$accessToken) {
\Domain\Integrations\ApiloLogger::log(
$mdb,
'resend_order',
$orderId,
'Nie udalo sie uzyskac tokenu Apilo (access token)',
['apilo_order_id' => $order['apilo_order_id']]
);
return false;
}
@@ -417,13 +450,29 @@ class OrderAdminService
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$apiloResultRaw = curl_exec($ch);
$http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$apiloResult = json_decode((string)$apiloResultRaw, true);
if (!is_array($apiloResult) || (int)($apiloResult['updates'] ?? 0) !== 1) {
\Domain\Integrations\ApiloLogger::log(
$mdb,
'resend_order',
$orderId,
'Błąd ponownego wysyłania zamówienia do Apilo (HTTP: ' . $http_code . ')',
['apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code, 'response' => $apiloResult]
);
curl_close($ch);
return false;
}
\Domain\Integrations\ApiloLogger::log(
$mdb,
'resend_order',
$orderId,
'Zamówienie ponownie wysłane do Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ')',
['apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code, 'response' => $apiloResult]
);
$query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_orders' AND COLUMN_NAME != 'id'";
$stmt = $mdb->query($query);
$columns = $stmt ? $stmt->fetchAll(\PDO::FETCH_COLUMN) : [];
@@ -474,75 +523,6 @@ class OrderAdminService
return $this->orders->deleteOrder($orderId);
}
// =========================================================================
// Apilo sync queue (migrated from \shop\Order)
// =========================================================================
private const APILO_SYNC_QUEUE_FILE = '/temp/apilo-sync-queue.json';
public function processApiloSyncQueue(int $limit = 10): int
{
$queue = self::loadApiloSyncQueue();
if (!\Shared\Helpers\Helpers::is_array_fix($queue)) {
return 0;
}
$processed = 0;
foreach ($queue as $key => $task)
{
if ($processed >= $limit) {
break;
}
$order_id = (int)($task['order_id'] ?? 0);
if ($order_id <= 0) {
unset($queue[$key]);
continue;
}
$order = $this->orders->findRawById($order_id);
if (!$order) {
unset($queue[$key]);
continue;
}
$error = '';
$sync_failed = false;
$payment_pending = !empty($task['payment']) && (int)$order['paid'] === 1;
if ($payment_pending && (int)$order['apilo_order_id']) {
if (!$this->syncApiloPayment($order)) {
$sync_failed = true;
$error = 'payment_sync_failed';
}
}
$status_pending = isset($task['status']) && $task['status'] !== null && $task['status'] !== '';
if (!$sync_failed && $status_pending && (int)$order['apilo_order_id']) {
if (!$this->syncApiloStatus($order, (int)$task['status'])) {
$sync_failed = true;
$error = 'status_sync_failed';
}
}
if ($sync_failed) {
$task['attempts'] = (int)($task['attempts'] ?? 0) + 1;
$task['last_error'] = $error;
$task['updated_at'] = date('Y-m-d H:i:s');
$queue[$key] = $task;
} else {
unset($queue[$key]);
}
$processed++;
}
self::saveApiloSyncQueue($queue);
return $processed;
}
// =========================================================================
// Private: email
// =========================================================================
@@ -600,6 +580,17 @@ class OrderAdminService
$apilo_settings = $integrationsRepository->getSettings('apilo');
if (!$apilo_settings['enabled'] || !$apilo_settings['access-token'] || !$apilo_settings['sync_orders']) {
\Domain\Integrations\ApiloLogger::log(
$db,
'payment_sync',
(int)$order['id'],
'Pominięto sync płatności — Apilo wyłączone lub brak tokenu/sync_orders',
[
'enabled' => $apilo_settings['enabled'] ?? false,
'has_token' => !empty($apilo_settings['access-token']),
'sync_orders' => $apilo_settings['sync_orders'] ?? false,
]
);
return;
}
@@ -607,8 +598,25 @@ class OrderAdminService
self::appendApiloLog("SET AS PAID\n" . print_r($order, true));
}
if ($order['apilo_order_id'] && !$this->syncApiloPayment($order)) {
self::queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
if (!$order['apilo_order_id']) {
// Zamówienie jeszcze nie wysłane do Apilo — kolejkuj sync płatności na później
\Domain\Integrations\ApiloLogger::log(
$db,
'payment_sync',
(int)$order['id'],
'Brak apilo_order_id — płatność zakolejkowana do sync',
['apilo_order_id' => $order['apilo_order_id'] ?? null]
);
$this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
} elseif (!$this->syncApiloPayment($order)) {
\Domain\Integrations\ApiloLogger::log(
$db,
'payment_sync',
(int)$order['id'],
'Sync płatności nieudany — zakolejkowano ponowną próbę',
['apilo_order_id' => $order['apilo_order_id']]
);
$this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
}
}
@@ -621,6 +629,18 @@ class OrderAdminService
$apilo_settings = $integrationsRepository->getSettings('apilo');
if (!$apilo_settings['enabled'] || !$apilo_settings['access-token'] || !$apilo_settings['sync_orders']) {
\Domain\Integrations\ApiloLogger::log(
$db,
'status_sync',
(int)$order['id'],
'Pominięto sync statusu — Apilo wyłączone lub brak tokenu/sync_orders',
[
'target_status' => $status,
'enabled' => $apilo_settings['enabled'] ?? false,
'has_token' => !empty($apilo_settings['access-token']),
'sync_orders' => $apilo_settings['sync_orders'] ?? false,
]
);
return;
}
@@ -628,19 +648,36 @@ class OrderAdminService
self::appendApiloLog("UPDATE STATUS\n" . print_r($order, true));
}
if ($order['apilo_order_id'] && !$this->syncApiloStatus($order, $status)) {
self::queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
if (!$order['apilo_order_id']) {
// Zamówienie jeszcze nie wysłane do Apilo — kolejkuj sync statusu na później
\Domain\Integrations\ApiloLogger::log(
$db,
'status_sync',
(int)$order['id'],
'Brak apilo_order_id — status zakolejkowany do sync',
['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status]
);
$this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
} elseif (!$this->syncApiloStatus($order, $status)) {
\Domain\Integrations\ApiloLogger::log(
$db,
'status_sync',
(int)$order['id'],
'Sync statusu nieudany — zakolejkowano ponowną próbę',
['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status]
);
$this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
}
}
private function syncApiloPayment(array $order): bool
public function syncApiloPayment(array $order): bool
{
global $config;
$db = $this->orders->getDb();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
if (!(int)$order['apilo_order_id']) {
if (empty($order['apilo_order_id'])) {
return true;
}
@@ -677,20 +714,37 @@ class OrderAdminService
self::appendApiloLog("PAYMENT RESPONSE\nHTTP: " . $http_code . "\nCURL: " . $curl_error . "\n" . print_r($apilo_response, true));
}
$success = ($curl_error === '' && $http_code >= 200 && $http_code < 300);
\Domain\Integrations\ApiloLogger::log(
$db,
'payment_sync',
(int)$order['id'],
$success
? 'Płatność zsynchronizowana z Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ')'
: 'Błąd synchronizacji płatności (HTTP: ' . $http_code . ($curl_error ? ', cURL: ' . $curl_error : '') . ')',
[
'apilo_order_id' => $order['apilo_order_id'],
'http_code' => $http_code,
'curl_error' => $curl_error,
'response' => json_decode((string)$apilo_response, true),
]
);
if ($curl_error !== '') return false;
if ($http_code < 200 || $http_code >= 300) return false;
return true;
}
private function syncApiloStatus(array $order, int $status): bool
public function syncApiloStatus(array $order, int $status): bool
{
global $config;
$db = $this->orders->getDb();
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
if (!(int)$order['apilo_order_id']) {
if (empty($order['apilo_order_id'])) {
return true;
}
@@ -721,6 +775,24 @@ class OrderAdminService
self::appendApiloLog("STATUS RESPONSE\nHTTP: " . $http_code . "\nCURL: " . $curl_error . "\n" . print_r($apilo_result, true));
}
$success = ($curl_error === '' && $http_code >= 200 && $http_code < 300);
\Domain\Integrations\ApiloLogger::log(
$db,
'status_sync',
(int)$order['id'],
$success
? 'Status zsynchronizowany z Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ', status: ' . $status . ')'
: 'Błąd synchronizacji statusu (HTTP: ' . $http_code . ($curl_error ? ', cURL: ' . $curl_error : '') . ')',
[
'apilo_order_id' => $order['apilo_order_id'],
'status' => $status,
'http_code' => $http_code,
'curl_error' => $curl_error,
'response' => json_decode((string)$apilo_result, true),
]
);
if ($curl_error !== '') return false;
if ($http_code < 200 || $http_code >= 300) return false;
@@ -728,59 +800,42 @@ class OrderAdminService
}
// =========================================================================
// Private: Apilo sync queue file helpers
// Private: Apilo sync queue (DB-based via CronJobRepository)
// =========================================================================
private static function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
private function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
{
if ($order_id <= 0) return;
$queue = self::loadApiloSyncQueue();
$key = (string)$order_id;
$row = is_array($queue[$key] ?? null) ? $queue[$key] : [];
if ($this->cronJobRepo === null) return;
if ($payment) {
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT;
$payload = ['order_id' => $order_id];
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
$this->cronJobRepo->enqueue(
$jobType,
$payload,
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
50
);
}
}
$row['order_id'] = $order_id;
$row['payment'] = !empty($row['payment']) || $payment ? 1 : 0;
if ($status !== null) {
$row['status'] = $status;
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_STATUS;
$payload = ['order_id' => $order_id, 'status' => $status];
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
$this->cronJobRepo->enqueue(
$jobType,
$payload,
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
50
);
}
}
$row['attempts'] = (int)($row['attempts'] ?? 0) + 1;
$row['last_error'] = $error;
$row['updated_at'] = date('Y-m-d H:i:s');
$queue[$key] = $row;
self::saveApiloSyncQueue($queue);
}
private static function apiloSyncQueuePath(): string
{
return dirname(__DIR__, 2) . self::APILO_SYNC_QUEUE_FILE;
}
private static function loadApiloSyncQueue(): array
{
$path = self::apiloSyncQueuePath();
if (!file_exists($path)) return [];
$content = file_get_contents($path);
if (!$content) return [];
$decoded = json_decode($content, true);
if (!is_array($decoded)) return [];
return $decoded;
}
private static function saveApiloSyncQueue(array $queue): void
{
$path = self::apiloSyncQueuePath();
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
private static function appendApiloLog(string $message): void

View File

@@ -245,25 +245,43 @@ class OrderRepository
public function orderStatuses(): array
{
$rows = $this->db->select('pp_shop_statuses', ['id', 'status'], [
$data = $this->orderStatusData();
return $data['names'];
}
/**
* Zwraca nazwy i kolory statusów w jednym zapytaniu.
*
* @return array{names: array<int, string>, colors: array<int, string>}
*/
public function orderStatusData(): array
{
$rows = $this->db->select('pp_shop_statuses', ['id', 'status', 'color'], [
'ORDER' => ['o' => 'ASC'],
]);
$names = [];
$colors = [];
if (!is_array($rows)) {
return [];
return ['names' => $names, 'colors' => $colors];
}
$result = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id < 0) {
continue;
}
$result[$id] = (string)($row['status'] ?? '');
$names[$id] = (string)($row['status'] ?? '');
$color = trim((string)($row['color'] ?? ''));
if ($color !== '' && preg_match('/^#[0-9a-fA-F]{3,6}$/', $color)) {
$colors[$id] = $color;
}
}
return $result;
return ['names' => $names, 'colors' => $colors];
}
public function nextOrderId(int $orderId): ?int

View File

@@ -120,10 +120,16 @@ class PaymentMethodRepository
'description' => trim((string)($data['description'] ?? '')),
'status' => $this->toSwitchValue($data['status'] ?? 0),
'apilo_payment_type_id' => $this->normalizeApiloPaymentTypeId($data['apilo_payment_type_id'] ?? null),
'min_order_amount' => $this->normalizeDecimalOrNull($data['min_order_amount'] ?? null),
'max_order_amount' => $this->normalizeDecimalOrNull($data['max_order_amount'] ?? null),
];
$this->db->update('pp_shop_payment_methods', $row, ['id' => $paymentMethodId]);
$cacheHandler = new \Shared\Cache\CacheHandler();
$cacheHandler->deletePattern('payment_method*');
$cacheHandler->deletePattern('payment_methods*');
return $paymentMethodId;
}
@@ -232,7 +238,9 @@ class PaymentMethodRepository
spm.name,
spm.description,
spm.status,
spm.apilo_payment_type_id
spm.apilo_payment_type_id,
spm.min_order_amount,
spm.max_order_amount
FROM pp_shop_payment_methods AS spm
INNER JOIN pp_shop_transport_payment_methods AS stpm
ON stpm.id_payment_method = spm.id
@@ -325,6 +333,8 @@ class PaymentMethodRepository
$row['description'] = (string)($row['description'] ?? '');
$row['status'] = $this->toSwitchValue($row['status'] ?? 0);
$row['apilo_payment_type_id'] = $this->normalizeApiloPaymentTypeId($row['apilo_payment_type_id'] ?? null);
$row['min_order_amount'] = $this->normalizeDecimalOrNull($row['min_order_amount'] ?? null);
$row['max_order_amount'] = $this->normalizeDecimalOrNull($row['max_order_amount'] ?? null);
return $row;
}
@@ -350,6 +360,23 @@ class PaymentMethodRepository
return $text;
}
/**
* @return float|null
*/
private function normalizeDecimalOrNull($value)
{
if ($value === null || $value === false) {
return null;
}
$text = trim((string)$value);
if ($text === '') {
return null;
}
return (float)$text;
}
private function toSwitchValue($value): int
{
if (is_bool($value)) {

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

@@ -508,6 +508,31 @@ class ProductRepository
$params[':promoted'] = (int)$promotedFilter;
}
// Attribute filters: attribute_{id} = {value_id}
$attrFilters = isset($filters['attributes']) && is_array($filters['attributes']) ? $filters['attributes'] : [];
$attrIdx = 0;
foreach ($attrFilters as $attrId => $valueId) {
$attrId = (int)$attrId;
$valueId = (int)$valueId;
if ($attrId <= 0 || $valueId <= 0) {
continue;
}
$paramAttr = ':attr_id_' . $attrIdx;
$paramVal = ':attr_val_' . $attrIdx;
$where[] = "EXISTS (
SELECT 1
FROM pp_shop_products AS psp_var{$attrIdx}
INNER JOIN pp_shop_products_attributes AS pspa{$attrIdx}
ON pspa{$attrIdx}.product_id = psp_var{$attrIdx}.id
WHERE psp_var{$attrIdx}.parent_id = psp.id
AND pspa{$attrIdx}.attribute_id = {$paramAttr}
AND pspa{$attrIdx}.value_id = {$paramVal}
)";
$params[$paramAttr] = $attrId;
$params[$paramVal] = $valueId;
$attrIdx++;
}
$whereSql = implode(' AND ', $where);
$sqlCount = "
@@ -632,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'],
];
@@ -657,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,
];
}
}
@@ -681,21 +708,444 @@ class ProductRepository
}
}
// Attributes
// Attributes (enriched with names) — batch-loaded
$attributes = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $id]);
$result['attributes'] = [];
if (is_array($attributes)) {
if (is_array($attributes) && !empty($attributes)) {
$attrIds = [];
$valueIds = [];
foreach ($attributes as $attr) {
$attrIds[] = (int)$attr['attribute_id'];
$valueIds[] = (int)$attr['value_id'];
}
$attrNamesMap = $this->batchLoadAttributeNames($attrIds);
$valueNamesMap = $this->batchLoadValueNames($valueIds);
$attrTypesMap = $this->batchLoadAttributeTypes($attrIds);
foreach ($attributes as $attr) {
$attrId = (int)$attr['attribute_id'];
$valId = (int)$attr['value_id'];
$result['attributes'][] = [
'attribute_id' => (int)$attr['attribute_id'],
'value_id' => (int)$attr['value_id'],
'attribute_id' => $attrId,
'attribute_type' => isset($attrTypesMap[$attrId]) ? $attrTypesMap[$attrId] : 0,
'attribute_names' => isset($attrNamesMap[$attrId]) ? $attrNamesMap[$attrId] : [],
'value_id' => $valId,
'value_names' => isset($valueNamesMap[$valId]) ? $valueNamesMap[$valId] : [],
];
}
}
// 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);
}
return $result;
}
/**
* Pobiera warianty produktu z atrybutami i tłumaczeniami dla REST API.
*
* @param int $productId ID produktu nadrzędnego
* @return array Lista wariantów
*/
public function findVariantsForApi(int $productId): array
{
$stmt = $this->db->query(
'SELECT id, permutation_hash, sku, ean, price_brutto, price_brutto_promo,
price_netto, price_netto_promo, quantity, stock_0_buy, weight, status
FROM pp_shop_products
WHERE parent_id = :pid
ORDER BY id ASC',
[':pid' => $productId]
);
$rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : [];
if (!is_array($rows)) {
return [];
}
// Collect all variant IDs, then load attributes in batch
$variantIds = [];
foreach ($rows as $row) {
$variantIds[] = (int)$row['id'];
}
// Load all attributes for all variants at once
$allAttrsRaw = [];
if (!empty($variantIds)) {
$allAttrsRaw = $this->db->select('pp_shop_products_attributes', ['product_id', 'attribute_id', 'value_id'], ['product_id' => $variantIds]);
if (!is_array($allAttrsRaw)) {
$allAttrsRaw = [];
}
}
// Group by variant and collect unique IDs for batch loading
$attrsByVariant = [];
$allAttrIds = [];
$allValueIds = [];
foreach ($allAttrsRaw as $a) {
$pid = (int)$a['product_id'];
$aId = (int)$a['attribute_id'];
$vId = (int)$a['value_id'];
$attrsByVariant[$pid][] = ['attribute_id' => $aId, 'value_id' => $vId];
$allAttrIds[] = $aId;
$allValueIds[] = $vId;
}
$attrNamesMap = $this->batchLoadAttributeNames($allAttrIds);
$valueNamesMap = $this->batchLoadValueNames($allValueIds);
$variants = [];
foreach ($rows as $row) {
$variantId = (int)$row['id'];
$variantAttrs = [];
if (isset($attrsByVariant[$variantId])) {
foreach ($attrsByVariant[$variantId] as $a) {
$aId = $a['attribute_id'];
$vId = $a['value_id'];
$variantAttrs[] = [
'attribute_id' => $aId,
'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [],
'value_id' => $vId,
'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [],
];
}
}
$variants[] = [
'id' => $variantId,
'permutation_hash' => $row['permutation_hash'],
'sku' => $row['sku'],
'ean' => $row['ean'],
'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null,
'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null,
'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null,
'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null,
'quantity' => (int)$row['quantity'],
'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0),
'weight' => $row['weight'] !== null ? (float)$row['weight'] : null,
'status' => (int)$row['status'],
'attributes' => $variantAttrs,
];
}
return $variants;
}
/**
* Pobiera pojedynczy wariant po ID dla REST API.
*
* @param int $variantId ID wariantu
* @return array|null Dane wariantu lub null
*/
public function findVariantForApi(int $variantId): ?array
{
$row = $this->db->get('pp_shop_products', '*', ['id' => $variantId]);
if (!$row || empty($row['parent_id'])) {
return null;
}
$attrs = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $variantId]);
$variantAttrs = [];
if (is_array($attrs) && !empty($attrs)) {
$attrIds = [];
$valueIds = [];
foreach ($attrs as $a) {
$attrIds[] = (int)$a['attribute_id'];
$valueIds[] = (int)$a['value_id'];
}
$attrNamesMap = $this->batchLoadAttributeNames($attrIds);
$valueNamesMap = $this->batchLoadValueNames($valueIds);
foreach ($attrs as $a) {
$aId = (int)$a['attribute_id'];
$vId = (int)$a['value_id'];
$variantAttrs[] = [
'attribute_id' => $aId,
'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [],
'value_id' => $vId,
'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [],
];
}
}
return [
'id' => (int)$row['id'],
'parent_id' => (int)$row['parent_id'],
'permutation_hash' => $row['permutation_hash'],
'sku' => $row['sku'],
'ean' => $row['ean'],
'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null,
'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null,
'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null,
'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null,
'quantity' => (int)$row['quantity'],
'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0),
'weight' => $row['weight'] !== null ? (float)$row['weight'] : null,
'status' => (int)$row['status'],
'attributes' => $variantAttrs,
];
}
/**
* Tworzy nowy wariant (kombinację) produktu przez API.
*
* @param int $parentId ID produktu nadrzędnego
* @param array $data Dane wariantu (attributes, sku, ean, price_brutto, etc.)
* @return array|null ['id' => int, 'permutation_hash' => string] lub null przy błędzie
*/
public function createVariantForApi(int $parentId, array $data): ?array
{
$parent = $this->db->get('pp_shop_products', ['id', 'archive', 'parent_id', 'vat'], ['id' => $parentId]);
if (!$parent) {
return null;
}
if (!empty($parent['archive'])) {
return null;
}
if (!empty($parent['parent_id'])) {
return null;
}
$attributes = isset($data['attributes']) && is_array($data['attributes']) ? $data['attributes'] : [];
if (empty($attributes)) {
return null;
}
// Build permutation hash
ksort($attributes);
$hashParts = [];
foreach ($attributes as $attrId => $valueId) {
$hashParts[] = (int)$attrId . '-' . (int)$valueId;
}
$permutationHash = implode('|', $hashParts);
// Check duplicate
$existing = $this->db->count('pp_shop_products', [
'AND' => [
'parent_id' => $parentId,
'permutation_hash' => $permutationHash,
],
]);
if ($existing > 0) {
return null;
}
$insertData = [
'parent_id' => $parentId,
'permutation_hash' => $permutationHash,
'vat' => $parent['vat'] ?? 0,
'sku' => isset($data['sku']) ? (string)$data['sku'] : null,
'ean' => isset($data['ean']) ? (string)$data['ean'] : null,
'price_brutto' => isset($data['price_brutto']) ? (float)$data['price_brutto'] : null,
'price_netto' => isset($data['price_netto']) ? (float)$data['price_netto'] : null,
'quantity' => isset($data['quantity']) ? (int)$data['quantity'] : 0,
'stock_0_buy' => isset($data['stock_0_buy']) ? (int)$data['stock_0_buy'] : 0,
'weight' => isset($data['weight']) ? (float)$data['weight'] : null,
'status' => 1,
];
$this->db->insert('pp_shop_products', $insertData);
$variantId = (int)$this->db->id();
if ($variantId <= 0) {
return null;
}
// Insert attribute rows
foreach ($attributes as $attrId => $valueId) {
$this->db->insert('pp_shop_products_attributes', [
'product_id' => $variantId,
'attribute_id' => (int)$attrId,
'value_id' => (int)$valueId,
]);
}
return [
'id' => $variantId,
'permutation_hash' => $permutationHash,
];
}
/**
* Aktualizuje wariant produktu przez API.
*
* @param int $variantId ID wariantu
* @param array $data Pola do aktualizacji
* @return bool true jeśli sukces
*/
public function updateVariantForApi(int $variantId, array $data): bool
{
$variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]);
if (!$variant || empty($variant['parent_id'])) {
return false;
}
$casts = [
'sku' => 'string',
'ean' => 'string',
'price_brutto' => 'float_or_null',
'price_netto' => 'float_or_null',
'price_brutto_promo' => 'float_or_null',
'price_netto_promo' => 'float_or_null',
'quantity' => 'int',
'stock_0_buy' => 'int',
'weight' => 'float_or_null',
'status' => 'int',
];
$updateData = [];
foreach ($casts as $field => $type) {
if (array_key_exists($field, $data)) {
$value = $data[$field];
if ($type === 'string') {
$updateData[$field] = ($value !== null) ? (string)$value : '';
} elseif ($type === 'int') {
$updateData[$field] = (int)$value;
} elseif ($type === 'float_or_null') {
$updateData[$field] = ($value !== null && $value !== '') ? (float)$value : null;
}
}
}
if (empty($updateData)) {
return true;
}
$this->db->update('pp_shop_products', $updateData, ['id' => $variantId]);
return true;
}
/**
* Usuwa wariant produktu przez API.
*
* @param int $variantId ID wariantu
* @return bool true jeśli sukces
*/
public function deleteVariantForApi(int $variantId): bool
{
$variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]);
if (!$variant || empty($variant['parent_id'])) {
return false;
}
$this->db->delete('pp_shop_products_langs', ['product_id' => $variantId]);
$this->db->delete('pp_shop_products_attributes', ['product_id' => $variantId]);
$this->db->delete('pp_shop_products', ['id' => $variantId]);
return true;
}
/**
* Batch-loads attribute names for multiple attribute IDs.
*
* @param int[] $attrIds
* @return array<int, array<string, string>> [attrId => [langId => name]]
*/
private function batchLoadAttributeNames(array $attrIds): array
{
if (empty($attrIds)) {
return [];
}
$translations = $this->db->select(
'pp_shop_attributes_langs',
['attribute_id', 'lang_id', 'name'],
['attribute_id' => array_values(array_unique($attrIds))]
);
$result = [];
if (is_array($translations)) {
foreach ($translations as $t) {
$aId = (int)($t['attribute_id'] ?? 0);
$langId = (string)($t['lang_id'] ?? '');
if ($aId > 0 && $langId !== '') {
$result[$aId][$langId] = (string)($t['name'] ?? '');
}
}
}
return $result;
}
/**
* Batch-loads value names for multiple value IDs.
*
* @param int[] $valueIds
* @return array<int, array<string, string>> [valueId => [langId => name]]
*/
private function batchLoadValueNames(array $valueIds): array
{
if (empty($valueIds)) {
return [];
}
$translations = $this->db->select(
'pp_shop_attributes_values_langs',
['value_id', 'lang_id', 'name'],
['value_id' => array_values(array_unique($valueIds))]
);
$result = [];
if (is_array($translations)) {
foreach ($translations as $t) {
$vId = (int)($t['value_id'] ?? 0);
$langId = (string)($t['lang_id'] ?? '');
if ($vId > 0 && $langId !== '') {
$result[$vId][$langId] = (string)($t['name'] ?? '');
}
}
}
return $result;
}
/**
* Batch-loads attribute types for multiple attribute IDs.
*
* @param int[] $attrIds
* @return array<int, int> [attrId => type]
*/
private function batchLoadAttributeTypes(array $attrIds): array
{
if (empty($attrIds)) {
return [];
}
$rows = $this->db->select(
'pp_shop_attributes',
['id', 'type'],
['id' => array_values(array_unique($attrIds))]
);
$result = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$result[(int)$row['id']] = (int)($row['type'] ?? 0);
}
}
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().
*/
@@ -819,7 +1269,7 @@ class ProductRepository
$productData = [
'date_modify' => date( 'Y-m-d H:i:s' ),
'modify_by' => $userId,
'modify_by' => $userId !== null ? (int) $userId : 0,
'status' => ( $d['status'] ?? '' ) === 'on' ? 1 : 0,
'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ),
'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ),
@@ -881,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 );
@@ -1195,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 ] );
@@ -2939,12 +3393,18 @@ class ProductRepository
$attributes = \Shared\Helpers\Helpers::removeDuplicates($attributes, 'id');
$sorted = [];
$toSort = [];
foreach ($attributes as $key => $val) {
$row = [];
$row['id'] = $key;
$row['values'] = $val;
$sorted[$attrRepo->getAttributeOrder((int) $key)] = $row;
$toSort[] = ['order' => (int) $attrRepo->getAttributeOrder((int) $key), 'data' => $row];
}
usort($toSort, function ($a, $b) { return $a['order'] - $b['order']; });
$sorted = [];
foreach ($toSort as $i => $item) {
$sorted[$i + 1] = $item['data'];
}
return $sorted;

View File

@@ -71,6 +71,7 @@ class SettingsRepository
'infinitescroll' => $this->isEnabled($values['infinitescroll'] ?? null) ? 1 : 0,
'own_gtm_js' => $values['own_gtm_js'] ?? '',
'own_gtm_html' => $values['own_gtm_html'] ?? '',
'api_key' => $values['api_key'] ?? '',
];
$warehouseMessageZero = $values['warehouse_message_zero'] ?? [];

View File

@@ -323,7 +323,9 @@ class TransportRepository
$transports[] = $tr;
}
if ( \Shared\Helpers\Helpers::normalize_decimal( \Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon ) ) >= \Shared\Helpers\Helpers::normalize_decimal( $settings['free_delivery'] ) )
$products_summary = (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon );
if ( \Shared\Helpers\Helpers::normalize_decimal( $products_summary ) >= \Shared\Helpers\Helpers::normalize_decimal( $settings['free_delivery'] ) )
{
for ( $i = 0; $i < count( $transports ); $i++ ) {
if ( $transports[$i]['delivery_free'] == 1 ) {
@@ -332,7 +334,39 @@ class TransportRepository
}
}
return $transports;
// Ukryj transporty, dla których nie ma żadnej dostępnej formy płatności
$paymentMethodRepo = new \Domain\PaymentMethod\PaymentMethodRepository( $this->db );
$filtered = [];
foreach ( $transports as $tr )
{
$paymentMethods = $paymentMethodRepo->paymentMethodsByTransport( $tr['id'] );
$order_total = $products_summary + (float)$tr['cost'];
$has_available_pm = false;
foreach ( $paymentMethods as $pm )
{
$min = isset( $pm['min_order_amount'] ) ? (float)$pm['min_order_amount'] : null;
$max = isset( $pm['max_order_amount'] ) ? (float)$pm['max_order_amount'] : null;
$available = true;
if ( $min !== null && $min > 0 && $order_total < $min ) $available = false;
if ( $max !== null && $max > 0 && $order_total > $max ) $available = false;
if ( $available )
{
$has_available_pm = true;
break;
}
}
if ( $has_available_pm )
{
$filtered[] = $tr;
}
}
return $filtered;
}
/**

View File

@@ -61,10 +61,284 @@ class UpdateRepository
return [ 'success' => true, 'log' => $log, 'no_updates' => true ];
}
/**
* Dispatcher — próbuje pobrać manifest, jeśli jest → nowa ścieżka, jeśli brak → legacy.
*/
private function downloadAndApply( string $ver, string $dir, array $log ): array
{
$baseUrl = 'https://shoppro.project-dc.pl/updates/' . $dir;
$manifest = $this->downloadManifest( $baseUrl, $ver );
if ( $manifest !== null ) {
$log[] = '[INFO] Znaleziono manifest dla wersji ' . $ver;
return $this->downloadAndApplyWithManifest( $ver, $dir, $manifest, $log );
}
$log[] = '[INFO] Brak manifestu, używam trybu legacy';
return $this->downloadAndApplyLegacy( $ver, $dir, $log );
}
/**
* Pobiera manifest JSON dla danej wersji.
*
* @return array|null Zdekodowany manifest lub null jeśli brak
*/
private function downloadManifest( string $baseUrl, string $ver )
{
$manifestUrl = $baseUrl . '/ver_' . $ver . '_manifest.json';
$ch = curl_init( $manifestUrl );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_HEADER, false );
curl_setopt( $ch, CURLOPT_TIMEOUT, 15 );
$response = curl_exec( $ch );
$httpCode = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
if ( !$response || $httpCode !== 200 ) {
return null;
}
$manifest = json_decode( $response, true );
if ( !is_array( $manifest ) || !isset( $manifest['version'] ) ) {
return null;
}
return $manifest;
}
/**
* Aktualizacja z użyciem manifestu — checksum, backup, SQL z manifestu, usuwanie z manifestu.
*/
private function downloadAndApplyWithManifest( string $ver, string $dir, array $manifest, array $log ): array
{
$baseUrl = 'https://shoppro.project-dc.pl/updates/' . $dir;
$log[] = '[INFO] Tryb aktualizacji: manifest';
// 1. Pobieranie ZIP
$zipUrl = $baseUrl . '/ver_' . $ver . '.zip';
$log[] = '[INFO] Pobieranie pliku ZIP: ' . $zipUrl;
$file = @file_get_contents( $zipUrl );
if ( $file === false ) {
$log[] = '[ERROR] Nie udało się pobrać pliku ZIP';
return [ 'success' => false, 'log' => $log ];
}
$fileSize = strlen( $file );
$log[] = '[OK] Pobrano plik ZIP, rozmiar: ' . $fileSize . ' bajtów';
if ( $fileSize < 100 ) {
$log[] = '[ERROR] Plik ZIP jest za mały (prawdopodobnie błąd pobierania)';
return [ 'success' => false, 'log' => $log ];
}
$dlHandler = @fopen( 'update.zip', 'w' );
if ( !$dlHandler ) {
$log[] = '[ERROR] Nie udało się otworzyć pliku update.zip do zapisu';
return [ 'success' => false, 'log' => $log ];
}
$written = fwrite( $dlHandler, $file );
fclose( $dlHandler );
if ( $written === false || $written === 0 ) {
$log[] = '[ERROR] Nie udało się zapisać pliku ZIP';
return [ 'success' => false, 'log' => $log ];
}
$log[] = '[OK] Zapisano plik ZIP (' . $written . ' bajtów)';
// 2. Weryfikacja checksum
if ( isset( $manifest['checksum_zip'] ) ) {
$checksumResult = $this->verifyChecksum( 'update.zip', $manifest['checksum_zip'], $log );
$log = $checksumResult['log'];
if ( !$checksumResult['valid'] ) {
@unlink( 'update.zip' );
return [ 'success' => false, 'log' => $log ];
}
}
// 3. Backup plików przed nadpisaniem
$log = $this->createBackup( $manifest, $log );
// 4. SQL z manifestu
if ( !empty( $manifest['sql'] ) ) {
$log[] = '[INFO] Wykonywanie zapytań SQL z manifestu (' . count( $manifest['sql'] ) . ')';
$success = 0;
$errors = 0;
foreach ( $manifest['sql'] as $query ) {
$query = trim( $query );
if ( $query !== '' ) {
if ( $this->db->query( $query ) ) {
$success++;
} else {
$errors++;
$log[] = '[WARNING] Błąd SQL: ' . $query;
}
}
}
$log[] = '[INFO] Wykonano zapytania SQL - sukces: ' . $success . ', błędy: ' . $errors;
}
// 5. Usuwanie plików z manifestu
if ( !empty( $manifest['files']['deleted'] ) ) {
$deletedCount = 0;
foreach ( $manifest['files']['deleted'] as $relativePath ) {
$fullPath = '../' . $relativePath;
if ( file_exists( $fullPath ) ) {
if ( @unlink( $fullPath ) ) {
$deletedCount++;
} else {
$log[] = '[WARNING] Nie udało się usunąć pliku: ' . $fullPath;
}
}
}
$log[] = '[INFO] Usunięto plików: ' . $deletedCount;
}
// 6. Usuwanie katalogów z manifestu
if ( !empty( $manifest['directories_deleted'] ) ) {
$deletedDirs = 0;
foreach ( $manifest['directories_deleted'] as $dirPath ) {
$fullPath = '../' . $dirPath;
if ( is_dir( $fullPath ) ) {
\Shared\Helpers\Helpers::delete_dir( $fullPath );
$deletedDirs++;
}
}
$log[] = '[INFO] Usunięto katalogów: ' . $deletedDirs;
}
// 7. Rozpakowywanie ZIP
$log = $this->extractZip( 'update.zip', $log );
// 8. Aktualizacja wersji
$versionFile = '../libraries/version.ini';
$handle = @fopen( $versionFile, 'w' );
if ( !$handle ) {
$log[] = '[ERROR] Nie udało się otworzyć pliku version.ini do zapisu';
return [ 'success' => false, 'log' => $log ];
}
fwrite( $handle, $ver );
fclose( $handle );
$log[] = '[OK] Zaktualizowano plik version.ini do wersji: ' . $ver;
$log[] = '[SUCCESS] Aktualizacja do wersji ' . $ver . ' zakończona pomyślnie';
return [ 'success' => true, 'log' => $log ];
}
/**
* Weryfikuje sumę kontrolną pliku.
*
* @param string $filePath Ścieżka do pliku
* @param string $expectedChecksum Suma w formacie "sha256:abc123..."
* @param array $log Tablica logów
* @return array{valid: bool, log: array}
*/
private function verifyChecksum( string $filePath, string $expectedChecksum, array $log ): array
{
$parts = explode( ':', $expectedChecksum, 2 );
if ( count( $parts ) !== 2 ) {
$log[] = '[ERROR] Nieprawidłowy format sumy kontrolnej: ' . $expectedChecksum;
return [ 'valid' => false, 'log' => $log ];
}
$algorithm = $parts[0];
$expected = $parts[1];
$actual = @hash_file( $algorithm, $filePath );
if ( $actual === false ) {
$log[] = '[ERROR] Nie udało się obliczyć sumy kontrolnej pliku';
return [ 'valid' => false, 'log' => $log ];
}
if ( $actual !== $expected ) {
$log[] = '[ERROR] Suma kontrolna nie zgadza się! Oczekiwano: ' . $expected . ', otrzymano: ' . $actual;
return [ 'valid' => false, 'log' => $log ];
}
$log[] = '[OK] Suma kontrolna ZIP zgodna';
return [ 'valid' => true, 'log' => $log ];
}
/**
* Tworzy kopię zapasową plików przed aktualizacją.
*
* @param array $manifest Dane z manifestu
* @param array $log Tablica logów
* @return array Zaktualizowana tablica logów
*/
private function createBackup( array $manifest, array $log ): array
{
$version = isset( $manifest['version'] ) ? $manifest['version'] : 'unknown';
$backupDir = '../backups/' . str_replace( '.', '_', $version ) . '_' . date( 'Ymd_His' );
$log[] = '[INFO] Tworzenie kopii zapasowej w: ' . $backupDir;
$projectRoot = realpath( '../' );
if ( !$projectRoot ) {
$log[] = '[WARNING] Nie udało się określić katalogu projektu, pomijam backup';
return $log;
}
$filesToBackup = [];
if ( isset( $manifest['files']['modified'] ) && is_array( $manifest['files']['modified'] ) ) {
$filesToBackup = array_merge( $filesToBackup, $manifest['files']['modified'] );
}
if ( isset( $manifest['files']['deleted'] ) && is_array( $manifest['files']['deleted'] ) ) {
$filesToBackup = array_merge( $filesToBackup, $manifest['files']['deleted'] );
}
if ( empty( $filesToBackup ) ) {
$log[] = '[INFO] Brak plików do backupu';
return $log;
}
$backedUp = 0;
foreach ( $filesToBackup as $relativePath ) {
$sourcePath = $projectRoot . '/' . $relativePath;
if ( !file_exists( $sourcePath ) ) {
continue;
}
$targetPath = $backupDir . '/' . $relativePath;
$targetDir = dirname( $targetPath );
if ( !is_dir( $targetDir ) ) {
@mkdir( $targetDir, 0755, true );
}
if ( @copy( $sourcePath, $targetPath ) ) {
$backedUp++;
} else {
$log[] = '[WARNING] Nie udało się skopiować do backupu: ' . $relativePath;
}
}
$log[] = '[OK] Backup: skopiowano ' . $backedUp . ' plików';
@file_put_contents(
$backupDir . '/manifest.json',
json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE )
);
return $log;
}
/**
* Legacy — stary format aktualizacji (ZIP + _sql.txt + _files.txt).
*/
private function downloadAndApplyLegacy( string $ver, string $dir, array $log ): array
{
$baseUrl = 'https://shoppro.project-dc.pl/updates/' . $dir;
// Pobieranie ZIP
$zipUrl = $baseUrl . '/ver_' . $ver . '.zip';
$log[] = '[INFO] Pobieranie pliku ZIP: ' . $zipUrl;
@@ -142,7 +416,12 @@ class UpdateRepository
return $log;
}
$queries = explode( PHP_EOL, $response );
// Usunięcie UTF-8 BOM i normalizacja końców linii
$response = ltrim( $response, "\xEF\xBB\xBF" );
$response = str_replace( "\r\n", "\n", $response );
$response = str_replace( "\r", "\n", $response );
$queries = explode( "\n", $response );
$log[] = '[OK] Pobrano ' . count( $queries ) . ' zapytań SQL';
$success = 0;
$errors = 0;

View File

@@ -60,4 +60,9 @@ class Tpl
{
return $this->vars[$name];
}
public function __isset($name)
{
return isset($this->vars[$name]);
}
}

View File

@@ -423,7 +423,8 @@ class App
new \Domain\Order\OrderRepository( $mdb ),
$productRepo,
new \Domain\Settings\SettingsRepository( $mdb ),
new \Domain\Transport\TransportRepository( $mdb )
new \Domain\Transport\TransportRepository( $mdb ),
new \Domain\CronJob\CronJobRepository( $mdb )
),
$productRepo
);

View File

@@ -2,6 +2,7 @@
namespace admin\Controllers;
use Domain\Integrations\IntegrationsRepository;
use admin\ViewModels\Common\PaginatedTableViewModel;
class IntegrationsController
{
@@ -12,6 +13,114 @@ class IntegrationsController
$this->repository = $repository;
}
public function logs(): string
{
$sortableColumns = ['id', 'action', 'order_id', 'message', 'date'];
$filterDefinitions = [
[
'key' => 'log_action',
'label' => 'Akcja',
'type' => 'text',
],
[
'key' => 'message',
'label' => 'Wiadomosc',
'type' => 'text',
],
[
'key' => 'order_id',
'label' => 'ID zamowienia',
'type' => 'text',
],
];
$listRequest = \admin\Support\TableListRequestFactory::fromRequest(
$filterDefinitions,
$sortableColumns,
'id'
);
$result = $this->repository->getLogs(
$listRequest['filters'],
$listRequest['sortColumn'],
$listRequest['sortDir'],
$listRequest['page'],
$listRequest['perPage']
);
$rows = [];
$lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1;
foreach ( $result['items'] as $item ) {
$id = (int)($item['id'] ?? 0);
$context = trim( (string)($item['context'] ?? '') );
$contextHtml = '';
if ( $context !== '' ) {
$contextHtml = '<button class="btn btn-xs btn-default log-context-btn" data-id="' . $id . '">Pokaz</button>'
. '<pre class="log-context-pre" id="log-context-' . $id . '" style="display:none;max-height:300px;overflow:auto;margin-top:5px;font-size:11px;white-space:pre-wrap;">'
. htmlspecialchars( $context, ENT_QUOTES, 'UTF-8' )
. '</pre>';
}
$rows[] = [
'lp' => $lp++ . '.',
'action' => htmlspecialchars( (string)($item['action'] ?? ''), ENT_QUOTES, 'UTF-8' ),
'order_id' => $item['order_id'] ? (int)$item['order_id'] : '-',
'message' => htmlspecialchars( (string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8' ),
'context' => $contextHtml,
'date' => !empty( $item['date'] ) ? date( 'Y-m-d H:i:s', strtotime( (string)$item['date'] ) ) : '-',
];
}
$total = (int)$result['total'];
$totalPages = max( 1, (int)ceil( $total / $listRequest['perPage'] ) );
$viewModel = new PaginatedTableViewModel(
[
['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false],
['key' => 'date', 'sort_key' => 'date', 'label' => 'Data', 'class' => 'text-center', 'sortable' => true],
['key' => 'action', 'sort_key' => 'action', 'label' => 'Akcja', 'sortable' => true],
['key' => 'order_id', 'sort_key' => 'order_id', 'label' => 'Zamowienie', 'class' => 'text-center', 'sortable' => true],
['key' => 'message', 'sort_key' => 'message', 'label' => 'Wiadomosc', 'sortable' => true],
['key' => 'context', 'label' => 'Kontekst', 'sortable' => false, 'raw' => true],
],
$rows,
$listRequest['viewFilters'],
[
'column' => $listRequest['sortColumn'],
'dir' => $listRequest['sortDir'],
],
[
'page' => $listRequest['page'],
'per_page' => $listRequest['perPage'],
'total' => $total,
'total_pages' => $totalPages,
],
array_merge( $listRequest['queryFilters'], [
'sort' => $listRequest['sortColumn'],
'dir' => $listRequest['sortDir'],
'per_page' => $listRequest['perPage'],
] ),
$listRequest['perPageOptions'],
$sortableColumns,
'/admin/integrations/logs/',
'Brak wpisow w logach.'
);
return \Shared\Tpl\Tpl::view( 'integrations/logs', [
'viewModel' => $viewModel,
] );
}
public function logs_clear(): void
{
$this->repository->clearLogs();
\Shared\Helpers\Helpers::alert( 'Logi zostaly wyczyszczone.' );
header( 'Location: /admin/integrations/logs/' );
exit;
}
public function apilo_settings(): string
{
return \Shared\Tpl\Tpl::view( 'integrations/apilo-settings', [

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

@@ -73,26 +73,45 @@ class SettingsController
*/
public function globalSearchAjax(): void
{
global $mdb;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$phrase = trim((string)\Shared\Helpers\Helpers::get('q'));
if ($phrase === '' || mb_strlen($phrase) < 2) {
try {
$this->executeGlobalSearch();
} catch (\Throwable $e) {
echo json_encode([
'status' => 'ok',
'status' => 'error',
'items' => [],
]);
exit;
}
exit;
}
private function executeGlobalSearch(): void
{
global $mdb;
$phrase = isset($_REQUEST['q']) ? trim((string)$_REQUEST['q']) : '';
if ($phrase === '' || mb_strlen($phrase) < 2) {
echo json_encode(['status' => 'ok', 'items' => []]);
return;
}
$phrase = mb_substr($phrase, 0, 120);
$phraseNormalized = preg_replace('/\s+/', ' ', $phrase);
$phraseNormalized = trim((string)$phraseNormalized);
$phraseNormalized = trim((string)preg_replace('/\s+/', ' ', $phrase));
$like = '%' . $phrase . '%';
$likeNormalized = '%' . $phraseNormalized . '%';
$items = [];
$defaultLang = (string)$this->languagesRepository->defaultLanguage();
$defaultLang = '1';
try {
$defaultLang = (string)$this->languagesRepository->defaultLanguage();
} catch (\Throwable $e) {
// fallback to '1'
}
// --- Produkty ---
try {
$productStmt = $mdb->query(
'SELECT '
@@ -115,7 +134,10 @@ class SettingsController
$productStmt = false;
}
$productRows = $productStmt ? $productStmt->fetchAll() : [];
$productRows = ($productStmt && method_exists($productStmt, 'fetchAll'))
? $productStmt->fetchAll(\PDO::FETCH_ASSOC)
: [];
if (is_array($productRows)) {
foreach ($productRows as $row) {
$productId = (int)($row['id'] ?? 0);
@@ -147,6 +169,7 @@ class SettingsController
}
}
// --- Zamowienia ---
try {
$orderStmt = $mdb->query(
'SELECT '
@@ -178,7 +201,10 @@ class SettingsController
$orderStmt = false;
}
$orderRows = $orderStmt ? $orderStmt->fetchAll() : [];
$orderRows = ($orderStmt && method_exists($orderStmt, 'fetchAll'))
? $orderStmt->fetchAll(\PDO::FETCH_ASSOC)
: [];
if (is_array($orderRows)) {
foreach ($orderRows as $row) {
$orderId = (int)($row['id'] ?? 0);
@@ -214,11 +240,12 @@ class SettingsController
}
}
echo json_encode([
'status' => 'ok',
'items' => array_slice($items, 0, 20),
]);
exit;
$json = json_encode(['status' => 'ok', 'items' => array_slice($items, 0, 20)]);
if ($json === false) {
echo json_encode(['status' => 'ok', 'items' => []], JSON_UNESCAPED_UNICODE);
return;
}
echo $json;
}
/**
@@ -444,8 +471,7 @@ class SettingsController
'label' => 'Htaccess cache',
'tab' => 'system',
]),
FormField::text('api_key', [
'label' => 'Klucz API (ordersPRO)',
FormField::custom('api_key', $this->renderApiKeyField($data['api_key'] ?? ''), [
'tab' => 'system',
]),
@@ -533,4 +559,23 @@ class SettingsController
return $data;
}
private function renderApiKeyField(string $value): string
{
$escaped = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
$js = "var c='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',"
. "k='';for(var i=0;i<32;i++){k+=c.charAt(Math.floor(Math.random()*c.length));}"
. "document.getElementById('api_key').value=k;";
return '<div class="form-group row">'
. '<label class="col-lg-4 control-label">Klucz API:</label>'
. '<div class="col-lg-8">'
. '<div class="input-group">'
. '<input type="text" id="api_key" class="form-control" name="api_key" value="' . $escaped . '" />'
. '<span class="input-group-addon btn btn-info" onclick="' . htmlspecialchars($js, ENT_QUOTES, 'UTF-8') . '">Generuj</span>'
. '</div>'
. '</div>'
. '</div>';
}
}

View File

@@ -69,7 +69,9 @@ class ShopOrderController
$listRequest['perPage']
);
$statusesMap = $this->service->statuses();
$statusData = $this->service->statusData();
$statusesMap = $statusData['names'];
$statusColorsMap = $statusData['colors'];
$rows = [];
$lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1;
@@ -77,7 +79,15 @@ class ShopOrderController
$orderId = (int)($item['id'] ?? 0);
$orderNumber = (string)($item['number'] ?? '');
$statusId = (int)($item['status'] ?? 0);
$statusLabel = (string)($statusesMap[$statusId] ?? ('Status #' . $statusId));
$statusLabel = htmlspecialchars((string)($statusesMap[$statusId] ?? ('Status #' . $statusId)), ENT_QUOTES, 'UTF-8');
$statusColor = isset($statusColorsMap[$statusId]) ? $statusColorsMap[$statusId] : '';
if ($statusColor !== '') {
$textColor = $this->contrastTextColor($statusColor);
$statusHtml = '<span class="label" style="background-color:' . htmlspecialchars($statusColor, ENT_QUOTES, 'UTF-8') . ';color:' . $textColor . '">' . $statusLabel . '</span>';
} else {
$statusHtml = $statusLabel;
}
$rows[] = [
'lp' => $lp++ . '.',
@@ -86,13 +96,13 @@ class ShopOrderController
'paid' => ((int)($item['paid'] ?? 0) === 1)
? '<i class="fa fa-check text-success"></i>'
: '<i class="fa fa-times text-dark"></i>',
'status' => htmlspecialchars($statusLabel, ENT_QUOTES, 'UTF-8'),
'status' => $statusHtml,
'summary' => number_format((float)($item['summary'] ?? 0), 2, '.', ' ') . ' zł',
'client' => htmlspecialchars((string)($item['client'] ?? ''), ENT_QUOTES, 'UTF-8') . ' | zamówienia: <strong>' . (int)($item['total_orders'] ?? 0) . '</strong>',
'address' => (string)($item['address'] ?? ''),
'order_email' => (string)($item['order_email'] ?? ''),
'client_phone' => (string)($item['client_phone'] ?? ''),
'transport' => (string)($item['transport'] ?? ''),
'transport' => $this->sanitizeInlineHtml((string)($item['transport'] ?? '')),
'payment_method' => (string)($item['payment_method'] ?? ''),
'_actions' => [
[
@@ -127,7 +137,7 @@ class ShopOrderController
['key' => 'address', 'label' => 'Adres', 'sortable' => false],
['key' => 'order_email', 'sort_key' => 'order_email', 'label' => 'Email', 'sortable' => true],
['key' => 'client_phone', 'sort_key' => 'client_phone', 'label' => 'Telefon', 'sortable' => true],
['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true],
['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true, 'raw' => true],
['key' => 'payment_method', 'sort_key' => 'payment_method', 'label' => 'Płatność', 'sortable' => true],
],
$rows,
@@ -361,4 +371,26 @@ class ShopOrderController
return date('Y-m-d H:i', $ts);
}
}
private function contrastTextColor(string $hex): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6) {
return '#fff';
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
$luminance = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
return $luminance > 0.5 ? '#000' : '#fff';
}
private function sanitizeInlineHtml(string $html): string
{
$html = strip_tags($html, '<b><strong><i><em>');
return preg_replace('/<(b|strong|i|em)\s[^>]*>/i', '<$1>', $html);
}
}

View File

@@ -182,6 +182,8 @@ class ShopPaymentMethodController
'description' => (string)($paymentMethod['description'] ?? ''),
'status' => (int)($paymentMethod['status'] ?? 0),
'apilo_payment_type_id' => $paymentMethod['apilo_payment_type_id'] ?? '',
'min_order_amount' => $paymentMethod['min_order_amount'] ?? '',
'max_order_amount' => $paymentMethod['max_order_amount'] ?? '',
];
$fields = [
@@ -203,6 +205,16 @@ class ShopPaymentMethodController
'tab' => 'settings',
'rows' => 5,
]),
FormField::number('min_order_amount', [
'label' => 'Min. kwota zamowienia (PLN)',
'tab' => 'settings',
'step' => 0.01,
]),
FormField::number('max_order_amount', [
'label' => 'Maks. kwota zamowienia (PLN)',
'tab' => 'settings',
'step' => 0.01,
]),
FormField::select('apilo_payment_type_id', [
'label' => 'Typ platnosci Apilo',
'tab' => 'settings',

View File

@@ -95,7 +95,7 @@ class ShopProductController
. '<a href="/admin/shop_product/product_edit/id=' . $id . '">' . $name . '</a> '
. '<a href="#" class="text-muted duplicate-product" product-id="' . $id . '">duplikuj</a>'
. '</div>'
. '<small class="text-muted product-categories">' . $categories . '</small>'
. '<small class="text-muted product-categories product-categories--cats" title="' . $categories . '">' . $categories . '</small>'
. '<small class="text-muted product-categories">SKU: ' . $sku . ', EAN: ' . $ean . '</small>';
$priceHtml = '<input type="text" class="product-price form-control text-right" product-id="' . $id . '" value="' . htmlspecialchars( (string) $product['price_brutto'], ENT_QUOTES, 'UTF-8' ) . '" style="width: 75px;">';
@@ -140,6 +140,7 @@ class ShopProductController
}
}
$rows[] = $row;
}
@@ -547,6 +548,11 @@ class ShopProductController
FormAction::cancel( $backUrl ),
];
if ( $productId > 0 ) {
$previewUrl = $this->repository->getProductUrl( $productId );
$actions[] = FormAction::preview( $previewUrl );
}
return new FormEditViewModel(
'product-edit',
$title,
@@ -683,7 +689,7 @@ class ShopProductController
foreach ( $products as $key => $val ) {
if ( (int) $key !== $productId ) {
$selected = ( is_array( $product['products_related'] ?? null ) && in_array( $key, $product['products_related'] ) ) ? ' selected' : '';
$html .= '<option value="' . (int) $key . '"' . $selected . '>' . $this->escapeHtml( $val ) . '</option>';
$html .= '<option value="' . (int) $key . '"' . $selected . '>' . $this->escapeHtml( (string) $val ) . '</option>';
}
}
$html .= '</select></div></div>';

View File

@@ -14,9 +14,12 @@ class UpdateController
public function main_view(): string
{
$logContent = @file_get_contents( '../libraries/update_log.txt' );
return \Shared\Tpl\Tpl::view( 'update/main-view', [
'ver' => \Shared\Helpers\Helpers::get_version(),
'new_ver' => \Shared\Helpers\Helpers::get_new_version(),
'log' => $logContent ?: '',
] );
}
@@ -46,4 +49,17 @@ class UpdateController
echo json_encode( $response );
exit;
}
public function checkUpdate(): void
{
\Shared\Helpers\Helpers::set_session( 'new-version', null );
$newVer = \Shared\Helpers\Helpers::get_new_version();
$curVer = \Shared\Helpers\Helpers::get_version();
echo json_encode( [
'has_update' => $newVer > $curVer,
'new_ver' => $newVer,
] );
exit;
}
}

View File

@@ -56,6 +56,22 @@ class FormAction
);
}
/**
* Predefiniowana akcja Podgląd (otwiera w nowej karcie)
*/
public static function preview(string $url, string $label = 'Podgląd'): self
{
return new self(
'preview',
$label,
$url,
null,
'btn btn-info',
'link',
['target' => '_blank']
);
}
/**
* Predefiniowana akcja Anuluj
*/

View File

@@ -46,7 +46,7 @@ class ApiRouter
}
$controller->$action();
} catch (\Exception $e) {
} catch (\Throwable $e) {
self::sendError('INTERNAL_ERROR', 'Internal server error', 500);
}
}
@@ -87,18 +87,22 @@ class ApiRouter
$settingsRepo = new \Domain\Settings\SettingsRepository($db);
$productRepo = new \Domain\Product\ProductRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db);
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
$cronJobRepo = new \Domain\CronJob\CronJobRepository($db);
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo, $cronJobRepo);
return new Controllers\OrdersApiController($service, $orderRepo);
},
'products' => function () use ($db) {
$productRepo = new \Domain\Product\ProductRepository($db);
return new Controllers\ProductsApiController($productRepo);
$attrRepo = new \Domain\Attribute\AttributeRepository($db);
return new Controllers\ProductsApiController($productRepo, $attrRepo);
},
'dictionaries' => function () use ($db) {
$statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db);
$paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db);
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo);
$attrRepo = new \Domain\Attribute\AttributeRepository($db);
$producerRepo = new \Domain\Producer\ProducerRepository($db);
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo, $attrRepo, $producerRepo);
},
];
}

View File

@@ -2,6 +2,8 @@
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;
@@ -11,15 +13,21 @@ class DictionariesApiController
private $statusRepo;
private $transportRepo;
private $paymentRepo;
private $attrRepo;
private $producerRepo;
public function __construct(
ShopStatusRepository $statusRepo,
TransportRepository $transportRepo,
PaymentMethodRepository $paymentRepo
PaymentMethodRepository $paymentRepo,
AttributeRepository $attrRepo,
ProducerRepository $producerRepo
) {
$this->statusRepo = $statusRepo;
$this->transportRepo = $transportRepo;
$this->paymentRepo = $paymentRepo;
$this->attrRepo = $attrRepo;
$this->producerRepo = $producerRepo;
}
public function statuses(): void
@@ -79,4 +87,122 @@ class DictionariesApiController
ApiRouter::sendSuccess($result);
}
public function attributes(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$attributes = $this->attrRepo->listForApi();
ApiRouter::sendSuccess($attributes);
}
public function ensure_attribute(): 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;
}
$type = (int) ($body['type'] ?? 0);
$lang = trim((string) ($body['lang'] ?? 'pl'));
if ($lang === '') {
$lang = 'pl';
}
$result = $this->attrRepo->ensureAttributeForApi($name, $type, $lang);
if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) {
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute', 500);
return;
}
ApiRouter::sendSuccess([
'id' => (int) ($result['id'] ?? 0),
'created' => !empty($result['created']),
]);
}
public function ensure_attribute_value(): void
{
if (!ApiRouter::requireMethod('POST')) {
return;
}
$body = ApiRouter::getJsonBody();
if (!is_array($body)) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
$attributeId = (int) ($body['attribute_id'] ?? 0);
if ($attributeId <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid attribute_id', 400);
return;
}
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400);
return;
}
$lang = trim((string) ($body['lang'] ?? 'pl'));
if ($lang === '') {
$lang = 'pl';
}
$result = $this->attrRepo->ensureAttributeValueForApi($attributeId, $name, $lang);
if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) {
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute value', 500);
return;
}
ApiRouter::sendSuccess([
'id' => (int) ($result['id'] ?? 0),
'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

@@ -2,15 +2,18 @@
namespace api\Controllers;
use api\ApiRouter;
use Domain\Attribute\AttributeRepository;
use Domain\Product\ProductRepository;
class ProductsApiController
{
private $productRepo;
private $attrRepo;
public function __construct(ProductRepository $productRepo)
public function __construct(ProductRepository $productRepo, AttributeRepository $attrRepo)
{
$this->productRepo = $productRepo;
$this->attrRepo = $attrRepo;
}
public function list(): void
@@ -25,6 +28,20 @@ class ProductsApiController
'promoted' => isset($_GET['promoted']) ? $_GET['promoted'] : '',
];
// Attribute filters: attribute_{id}={value_id}
$attrFilters = [];
foreach ($_GET as $key => $value) {
if (strpos($key, 'attribute_') === 0) {
$attrId = (int)substr($key, 10);
if ($attrId > 0 && (int)$value > 0) {
$attrFilters[$attrId] = (int)$value;
}
}
}
if (!empty($attrFilters)) {
$filters['attributes'] = $attrFilters;
}
$sort = isset($_GET['sort']) ? $_GET['sort'] : 'id';
$sortDir = isset($_GET['sort_dir']) ? $_GET['sort_dir'] : 'DESC';
$page = max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1));
@@ -90,6 +107,11 @@ class ProductsApiController
return;
}
if (!is_numeric($body['price_brutto']) || (float)$body['price_brutto'] < 0) {
ApiRouter::sendError('BAD_REQUEST', 'price_brutto must be a non-negative number', 400);
return;
}
$formData = $this->mapApiToFormData($body);
$productId = $this->productRepo->saveProduct($formData);
@@ -139,6 +161,231 @@ class ProductsApiController
ApiRouter::sendSuccess($updated);
}
public function variants(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($id <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$product = $this->productRepo->find($id);
if ($product === null) {
ApiRouter::sendError('NOT_FOUND', 'Product not found', 404);
return;
}
if (!empty($product['parent_id'])) {
ApiRouter::sendError('BAD_REQUEST', 'Cannot get variants of a variant product', 400);
return;
}
$variants = $this->productRepo->findVariantsForApi($id);
// Available attributes for this product
$allAttributes = $this->attrRepo->listForApi();
$usedAttrIds = [];
foreach ($variants as $variant) {
foreach ($variant['attributes'] as $a) {
$usedAttrIds[(int)$a['attribute_id']] = true;
}
}
$availableAttributes = [];
foreach ($allAttributes as $attr) {
if (isset($usedAttrIds[$attr['id']])) {
$availableAttributes[] = $attr;
}
}
ApiRouter::sendSuccess([
'product_id' => $id,
'available_attributes' => $availableAttributes,
'variants' => $variants,
]);
}
public function create_variant(): void
{
if (!ApiRouter::requireMethod('POST')) {
return;
}
$parentId = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($parentId <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$body = ApiRouter::getJsonBody();
if ($body === null) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
if (empty($body['attributes']) || !is_array($body['attributes'])) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or empty attributes', 400);
return;
}
$result = $this->productRepo->createVariantForApi($parentId, $body);
if ($result === null) {
ApiRouter::sendError('BAD_REQUEST', 'Cannot create variant: parent not found, is archived, is itself a variant, or combination already exists', 400);
return;
}
$variant = $this->productRepo->findVariantForApi($result['id']);
http_response_code(201);
echo json_encode([
'status' => 'ok',
'data' => $variant !== null ? $variant : $result,
], JSON_UNESCAPED_UNICODE);
}
public function update_variant(): void
{
if (!ApiRouter::requireMethod('PUT')) {
return;
}
$variantId = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($variantId <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$body = ApiRouter::getJsonBody();
if ($body === null) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
$success = $this->productRepo->updateVariantForApi($variantId, $body);
if (!$success) {
ApiRouter::sendError('NOT_FOUND', 'Variant not found', 404);
return;
}
$variant = $this->productRepo->findVariantForApi($variantId);
ApiRouter::sendSuccess($variant);
}
public function delete_variant(): void
{
if (!ApiRouter::requireMethod('DELETE')) {
return;
}
$variantId = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($variantId <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$success = $this->productRepo->deleteVariantForApi($variantId);
if (!$success) {
ApiRouter::sendError('NOT_FOUND', 'Variant not found', 404);
return;
}
ApiRouter::sendSuccess(['id' => $variantId, 'deleted' => true]);
}
public function upload_image(): void
{
if (!ApiRouter::requireMethod('POST')) {
return;
}
$body = ApiRouter::getJsonBody();
if ($body === null) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
$productId = (int)($body['id'] ?? 0);
if ($productId <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid product id', 400);
return;
}
$product = $this->productRepo->find($productId);
if ($product === null) {
ApiRouter::sendError('NOT_FOUND', 'Product not found', 404);
return;
}
$fileName = trim((string)($body['file_name'] ?? ''));
$base64 = (string)($body['content_base64'] ?? '');
if ($fileName === '' || $base64 === '') {
ApiRouter::sendError('BAD_REQUEST', 'Missing file_name or content_base64', 400);
return;
}
$binary = base64_decode($base64, true);
if ($binary === false) {
ApiRouter::sendError('BAD_REQUEST', 'Invalid content_base64 payload', 400);
return;
}
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName));
if ($safeName === '' || $safeName === null) {
$safeName = 'image_' . md5((string)microtime(true)) . '.jpg';
}
// 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;
}
$targetPath = $baseDir . '/' . $safeName;
if (is_file($targetPath)) {
$name = pathinfo($safeName, PATHINFO_FILENAME);
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
$targetPath = $baseDir . '/' . $name . '_' . substr(md5($safeName . microtime(true)), 0, 8) . ($ext !== '' ? '.' . $ext : '');
}
if (file_put_contents($targetPath, $binary) === false) {
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to save image file', 500);
return;
}
$src = '/upload/product_images/product_' . $productId . '/' . basename($targetPath);
$alt = (string)($body['alt'] ?? '');
$position = isset($body['o']) ? (int)$body['o'] : null;
$db = $GLOBALS['mdb'] ?? null;
if (!$db) {
ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500);
return;
}
if ($position === null) {
$max = $db->max('pp_shop_products_images', 'o', ['product_id' => $productId]);
$position = (int)$max + 1;
}
$db->insert('pp_shop_products_images', [
'product_id' => $productId,
'src' => $src,
'alt' => $alt,
'o' => $position,
]);
ApiRouter::sendSuccess([
'src' => $src,
'alt' => $alt,
'o' => $position,
]);
}
/**
* Mapuje dane z JSON API na format oczekiwany przez saveProduct().
*
@@ -182,6 +429,11 @@ class ProductsApiController
}
}
// saveProduct() traktuje float 0.00 jako "puste", ale cena 0 musi pozostać jawnie ustawiona.
if (isset($d['price_brutto']) && is_numeric($d['price_brutto']) && (float)$d['price_brutto'] === 0.0) {
$d['price_brutto'] = '0';
}
// String fields — direct mapping
$stringFields = [
'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2',
@@ -246,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

@@ -177,9 +177,10 @@ class App
'ShopOrder' => function() {
global $mdb;
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
$cronJobRepo = new \Domain\CronJob\CronJobRepository( $mdb );
return new \front\Controllers\ShopOrderController(
$orderRepo,
new \Domain\Order\OrderAdminService( $orderRepo )
new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronJobRepo )
);
},
'ShopProducer' => function() {

View File

@@ -132,6 +132,11 @@ class ShopBasketController
$attributes[] = $val;
}
// Sort by attribute ID to match permutation_hash order (generated with ksort)
usort( $attributes, function ( $a, $b ) {
return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0];
} );
foreach( $values as $key => $val )
{
if ( strpos( $key, 'custom_field' ) !== false )
@@ -372,7 +377,9 @@ class ShopBasketController
'transport_id' => \Shared\Helpers\Helpers::get_session( 'basket-transport-method-id' ),
'transport_methods' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-transport-methods', [
'transports_methods' => ( new \Domain\Transport\TransportRepository( $GLOBALS['mdb'] ) )->transportMethodsFront( $basket, $coupon ),
'transport_id' => $basket_transport_method_id
'transport_id' => $basket_transport_method_id,
'free_delivery' => (float)($settings['free_delivery'] ?? 0),
'basket_summary' => (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon )
] ),
'payment_method_id' => $payment_method_id,
'basket_details' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-details', [
@@ -387,6 +394,8 @@ class ShopBasketController
private function jsonBasketResponse( $basket, $coupon, $lang_id, $basket_transport_method_id )
{
global $settings;
echo json_encode( [
'basket' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-details', [
'basket' => $basket,
@@ -398,7 +407,9 @@ class ShopBasketController
'products_count' => count( $basket ),
'transport_methods' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-transport-methods', [
'transports_methods' => ( new \Domain\Transport\TransportRepository( $GLOBALS['mdb'] ) )->transportMethodsFront( $basket, $coupon ),
'transport_id' => $basket_transport_method_id
'transport_id' => $basket_transport_method_id,
'free_delivery' => (float)($settings['free_delivery'] ?? 0),
'basket_summary' => (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon )
] )
] );
exit;

View File

@@ -80,16 +80,17 @@ class ShopProductController
{
global $lang_id;
$combination = '';
$selected_values = \Shared\Helpers\Helpers::get( 'selected_values' );
foreach ( $selected_values as $value )
{
$combination .= $value;
if ( $value != end( $selected_values ) )
$combination .= '|';
// Sort by attribute ID to match permutation_hash order (generated with ksort)
if ( is_array( $selected_values ) ) {
usort( $selected_values, function ( $a, $b ) {
return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0];
} );
}
$combination = is_array( $selected_values ) ? implode( '|', $selected_values ) : '';
$product_id = \Shared\Helpers\Helpers::get( 'product_id' );
$productRepo = new \Domain\Product\ProductRepository( $GLOBALS['mdb'] );
$product = $productRepo->findCached( $product_id, $lang_id );
@@ -102,6 +103,10 @@ class ShopProductController
private static function getPermutation( $attributes )
{
if ( !is_array( $attributes ) || !count( $attributes ) ) return null;
// Sort by attribute ID to match permutation_hash order (generated with ksort)
usort( $attributes, function ( $a, $b ) {
return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0];
} );
return implode( '|', $attributes );
}

View File

@@ -0,0 +1 @@
[]