feat(cronjob): implement CronJobProcessor and CronJobRepository for job scheduling and processing

- Added CronJobProcessor class to handle job creation and queue processing.
- Implemented CronJobRepository for database interactions related to cron jobs.
- Introduced CronJobType class to define job types, priorities, and statuses.
- Created ApiloLogger for logging actions related to job processing.
- Initialized apilo-sync-queue.json for job queue management.
This commit is contained in:
2026-02-27 14:51:30 +01:00
parent fc45bbf20e
commit 4cf7039759
34 changed files with 3099 additions and 741 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;