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:
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
140
autoload/Domain/CronJob/CronJobProcessor.php
Normal file
140
autoload/Domain/CronJob/CronJobProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
248
autoload/Domain/CronJob/CronJobRepository.php
Normal file
248
autoload/Domain/CronJob/CronJobRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
81
autoload/Domain/CronJob/CronJobType.php
Normal file
81
autoload/Domain/CronJob/CronJobType.php
Normal 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);
|
||||
}
|
||||
}
|
||||
30
autoload/Domain/Integrations/ApiloLogger.php
Normal file
30
autoload/Domain/Integrations/ApiloLogger.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
] );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] ?? [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user