Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52119a0724 | |||
| 97d7473753 | |||
| 754f004096 | |||
| 3b2d156e84 | |||
| c44f59894e | |||
| fe2a77e995 | |||
| f0b1152ab1 |
@@ -67,7 +67,10 @@
|
||||
"Bash(rm -rf \"C:/visual studio code/projekty/shopPRO/temp/temp_314\" && cd \"C:/visual studio code/projekty/shopPRO\" && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.313 -ToTag v0.314 -ChangelogEntry \"FIX - naprawa globalnej wyszukiwarki admin \\(Content-Type, Cache-Control, POST, try/catch\\), NEW - title strony z numerem zamówienia\" 2>&1)",
|
||||
"mcp__serena__initial_instructions",
|
||||
"mcp__serena__list_memories",
|
||||
"mcp__serena__find_referencing_symbols"
|
||||
"mcp__serena__find_referencing_symbols",
|
||||
"Bash(cd C:\\\\visual studio code\\\\projekty\\\\shopPRO:*)",
|
||||
"Bash(cd \"/c/visual studio code/projekty/shopPRO\" && rm -rf temp/temp_317 && powershell -ExecutionPolicy Bypass -File build-update.ps1 -FromTag v0.316 -ToTag v0.317 -ChangelogEntry \"FIX - klucz API: fix zapisu \\(brakowalo w whiteliście\\), przycisk Generuj losowy klucz, ulepszony routing API\" 2>&1)",
|
||||
"Bash(./test.ps1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -116,3 +116,10 @@ initial_prompt: ""
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
@@ -36,7 +36,7 @@ composer test
|
||||
|
||||
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
||||
|
||||
Current suite: **765 tests, 2153 assertions**.
|
||||
Current suite: **805 tests, 2253 assertions**.
|
||||
|
||||
### Creating Updates
|
||||
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs.
|
||||
@@ -116,7 +116,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro
|
||||
- Constructor DI with `$db` (Medoo instance)
|
||||
- Methods serve both admin and frontend (shared Domain, no separate services)
|
||||
|
||||
**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
|
||||
**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, CronJob, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User
|
||||
|
||||
**Admin Controllers** (`autoload/admin/Controllers/`):
|
||||
- DI via constructor (repositories injected)
|
||||
@@ -223,3 +223,5 @@ Before starting implementation, review current state of docs (see AGENTS.md for
|
||||
- `docs/CHANGELOG.md` — version history
|
||||
- `docs/API.md` — REST API documentation (ordersPRO)
|
||||
- `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages
|
||||
|
||||
## Za każdym razem jak próbujesz sprawdzić jakiś plik z logami spróbuj go najpierw pobrać z serwera FTP
|
||||
BIN
autoload/.DS_Store
vendored
BIN
autoload/.DS_Store
vendored
Binary file not shown.
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);
|
||||
}
|
||||
}
|
||||
@@ -747,26 +747,55 @@ class IntegrationsRepository
|
||||
|
||||
// Import images
|
||||
$images = $mdb2->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] );
|
||||
$importLog = [];
|
||||
$domainRaw = preg_replace( '#^https?://#', '', (string)($settings['domain'] ?? '') );
|
||||
if ( is_array( $images ) ) {
|
||||
foreach ( $images as $image ) {
|
||||
$imageUrl = 'https://' . $settings['domain'] . $image['src'];
|
||||
$srcPath = (string)($image['src'] ?? '');
|
||||
$imageUrl = 'https://' . rtrim( $domainRaw, '/' ) . '/' . ltrim( $srcPath, '/' );
|
||||
$imageName = basename( $srcPath );
|
||||
|
||||
if ( $imageName === '' ) {
|
||||
$importLog[] = '[SKIP] Pusta nazwa pliku dla src: ' . $srcPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ch = curl_init( $imageUrl );
|
||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
|
||||
curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
|
||||
curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false );
|
||||
$imageData = curl_exec( $ch );
|
||||
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
|
||||
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||
$imageData = curl_exec( $ch );
|
||||
$httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$curlErrno = curl_errno( $ch );
|
||||
$curlError = curl_error( $ch );
|
||||
curl_close( $ch );
|
||||
|
||||
$imageName = basename( $imageUrl );
|
||||
$imageDir = '../upload/product_images/product_' . $newProductId;
|
||||
if ( $curlErrno !== 0 || $imageData === false ) {
|
||||
$importLog[] = '[ERROR] cURL: ' . $imageUrl . ' — błąd ' . $curlErrno . ': ' . $curlError;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $httpCode !== 200 ) {
|
||||
$importLog[] = '[ERROR] HTTP ' . $httpCode . ': ' . $imageUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageDir = dirname( __DIR__, 3 ) . '/upload/product_images/product_' . $newProductId;
|
||||
$imagePath = $imageDir . '/' . $imageName;
|
||||
|
||||
if ( !file_exists( $imageDir ) )
|
||||
mkdir( $imageDir, 0777, true );
|
||||
if ( !file_exists( $imageDir ) && !mkdir( $imageDir, 0777, true ) && !file_exists( $imageDir ) ) {
|
||||
$importLog[] = '[ERROR] Nie można utworzyć katalogu: ' . $imageDir;
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents( $imagePath, $imageData );
|
||||
$written = file_put_contents( $imagePath, $imageData );
|
||||
if ( $written === false ) {
|
||||
$importLog[] = '[ERROR] Zapis pliku nieudany: ' . $imagePath;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->insert( 'pp_shop_products_images', [
|
||||
'product_id' => $newProductId,
|
||||
@@ -774,10 +803,50 @@ class IntegrationsRepository
|
||||
'alt' => $image['alt'] ?? '',
|
||||
'o' => $image['o'],
|
||||
] );
|
||||
$importLog[] = '[OK] ' . $imageUrl . ' → ' . $imagePath . ' (' . $written . ' B)';
|
||||
}
|
||||
}
|
||||
|
||||
return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ];
|
||||
// Zapisz log importu zdjęć (ścieżka absolutna — niezależna od cwd)
|
||||
$logDir = dirname( __DIR__, 3 ) . '/logs';
|
||||
$logFile = $logDir . '/shoppro-import-debug.log';
|
||||
$mkdirOk = file_exists( $logDir ) || mkdir( $logDir, 0755, true ) || file_exists( $logDir );
|
||||
$logEntry = '[' . date( 'Y-m-d H:i:s' ) . '] Import produktu #' . $productId . ' → #' . $newProductId . "\n"
|
||||
. ' Domain: ' . $domainRaw . "\n"
|
||||
. ' Obrazy źródłowe: ' . count( $images ?: [] ) . "\n";
|
||||
foreach ( $importLog as $line ) {
|
||||
$logEntry .= ' ' . $line . "\n";
|
||||
}
|
||||
// Zawsze loguj do error_log (niezależnie od uprawnień do pliku)
|
||||
error_log( '[shopPRO shoppro-import] ' . str_replace( "\n", ' | ', $logEntry ) );
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -519,92 +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;
|
||||
$max_attempts = 50; // ~8h przy cronie co 10 min
|
||||
|
||||
// Zamówienie jeszcze nie wysłane do Apilo — czekaj na crona
|
||||
if (!(int)$order['apilo_order_id']) {
|
||||
$attempts = (int)($task['attempts'] ?? 0) + 1;
|
||||
if ($attempts >= $max_attempts) {
|
||||
// Przekroczono limit prób — porzuć task
|
||||
unset($queue[$key]);
|
||||
} else {
|
||||
$task['attempts'] = $attempts;
|
||||
$task['last_error'] = 'awaiting_apilo_order';
|
||||
$task['updated_at'] = date('Y-m-d H:i:s');
|
||||
$queue[$key] = $task;
|
||||
}
|
||||
$processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$payment_pending = !empty($task['payment']) && (int)$order['paid'] === 1;
|
||||
if ($payment_pending) {
|
||||
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) {
|
||||
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
|
||||
// =========================================================================
|
||||
@@ -689,7 +607,7 @@ class OrderAdminService
|
||||
'Brak apilo_order_id — płatność zakolejkowana do sync',
|
||||
['apilo_order_id' => $order['apilo_order_id'] ?? null]
|
||||
);
|
||||
self::queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
|
||||
$this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
|
||||
} elseif (!$this->syncApiloPayment($order)) {
|
||||
\Domain\Integrations\ApiloLogger::log(
|
||||
$db,
|
||||
@@ -698,7 +616,7 @@ class OrderAdminService
|
||||
'Sync płatności nieudany — zakolejkowano ponowną próbę',
|
||||
['apilo_order_id' => $order['apilo_order_id']]
|
||||
);
|
||||
self::queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
|
||||
$this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +657,7 @@ class OrderAdminService
|
||||
'Brak apilo_order_id — status zakolejkowany do sync',
|
||||
['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status]
|
||||
);
|
||||
self::queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
|
||||
$this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
|
||||
} elseif (!$this->syncApiloStatus($order, $status)) {
|
||||
\Domain\Integrations\ApiloLogger::log(
|
||||
$db,
|
||||
@@ -748,11 +666,11 @@ class OrderAdminService
|
||||
'Sync statusu nieudany — zakolejkowano ponowną próbę',
|
||||
['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status]
|
||||
);
|
||||
self::queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
|
||||
$this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
|
||||
}
|
||||
}
|
||||
|
||||
private function syncApiloPayment(array $order): bool
|
||||
public function syncApiloPayment(array $order): bool
|
||||
{
|
||||
global $config;
|
||||
|
||||
@@ -819,7 +737,7 @@ class OrderAdminService
|
||||
return true;
|
||||
}
|
||||
|
||||
private function syncApiloStatus(array $order, int $status): bool
|
||||
public function syncApiloStatus(array $order, int $status): bool
|
||||
{
|
||||
global $config;
|
||||
|
||||
@@ -882,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
|
||||
|
||||
@@ -737,7 +737,16 @@ class ProductRepository
|
||||
|
||||
// Custom fields (Dodatkowe pola)
|
||||
$customFields = $this->db->select('pp_shop_products_custom_fields', ['name', 'type', 'is_required'], ['id_product' => $id]);
|
||||
$result['custom_fields'] = is_array($customFields) ? $customFields : [];
|
||||
$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'])) {
|
||||
@@ -1322,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 );
|
||||
@@ -1636,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 ] );
|
||||
|
||||
@@ -423,7 +423,8 @@ class App
|
||||
new \Domain\Order\OrderRepository( $mdb ),
|
||||
$productRepo,
|
||||
new \Domain\Settings\SettingsRepository( $mdb ),
|
||||
new \Domain\Transport\TransportRepository( $mdb )
|
||||
new \Domain\Transport\TransportRepository( $mdb ),
|
||||
new \Domain\CronJob\CronJobRepository( $mdb )
|
||||
),
|
||||
$productRepo
|
||||
);
|
||||
|
||||
@@ -106,6 +106,14 @@ class ProductArchiveController
|
||||
'confirm_ok' => 'Przywroc',
|
||||
'confirm_cancel' => 'Anuluj',
|
||||
],
|
||||
[
|
||||
'label' => 'Usun trwale',
|
||||
'url' => '/admin/product_archive/delete_permanent/product_id=' . $id,
|
||||
'class' => 'btn btn-xs btn-danger',
|
||||
'confirm' => 'UWAGA! Operacja nieodwracalna!' . "\n\n" . 'Produkt "' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" zostanie trwale usuniety razem ze wszystkimi zdjeciami i zalacznikami z serwera.' . "\n\n" . 'Czy na pewno chcesz usunac ten produkt?',
|
||||
'confirm_ok' => 'Tak, usun trwale',
|
||||
'confirm_cancel' => 'Anuluj',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -162,4 +170,24 @@ class ProductArchiveController
|
||||
header( 'Location: /admin/product_archive/list/' );
|
||||
exit;
|
||||
}
|
||||
|
||||
public function delete_permanent(): void
|
||||
{
|
||||
$productId = (int) \Shared\Helpers\Helpers::get( 'product_id' );
|
||||
|
||||
if ( $productId <= 0 ) {
|
||||
\Shared\Helpers\Helpers::alert( 'Nieprawidłowe ID produktu.' );
|
||||
header( 'Location: /admin/product_archive/list/' );
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( $this->productRepository->delete( $productId ) ) {
|
||||
\Shared\Helpers\Helpers::set_message( 'Produkt został trwale usunięty wraz ze zdjęciami i załącznikami.' );
|
||||
} else {
|
||||
\Shared\Helpers\Helpers::alert( 'Podczas usuwania produktu wystąpił błąd. Proszę spróbować ponownie.' );
|
||||
}
|
||||
|
||||
header( 'Location: /admin/product_archive/list/' );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@ class ApiRouter
|
||||
$settingsRepo = new \Domain\Settings\SettingsRepository($db);
|
||||
$productRepo = new \Domain\Product\ProductRepository($db);
|
||||
$transportRepo = new \Domain\Transport\TransportRepository($db);
|
||||
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
|
||||
$cronJobRepo = new \Domain\CronJob\CronJobRepository($db);
|
||||
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo, $cronJobRepo);
|
||||
return new Controllers\OrdersApiController($service, $orderRepo);
|
||||
},
|
||||
'products' => function () use ($db) {
|
||||
|
||||
@@ -338,7 +338,8 @@ class ProductsApiController
|
||||
$safeName = 'image_' . md5((string)microtime(true)) . '.jpg';
|
||||
}
|
||||
|
||||
$baseDir = '../upload/product_images/product_' . $productId;
|
||||
// api.php działa z rootu projektu (nie z admin/), więc ścieżka bez ../
|
||||
$baseDir = 'upload/product_images/product_' . $productId;
|
||||
if (!is_dir($baseDir) && !mkdir($baseDir, 0775, true) && !is_dir($baseDir)) {
|
||||
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create target directory', 500);
|
||||
return;
|
||||
@@ -507,7 +508,7 @@ class ProductsApiController
|
||||
continue;
|
||||
}
|
||||
$d['custom_field_name'][] = (string)$cf['name'];
|
||||
$d['custom_field_type'][] = isset($cf['type']) ? (string)$cf['type'] : 'text';
|
||||
$d['custom_field_type'][] = !empty($cf['type']) ? (string)$cf['type'] : 'text';
|
||||
$d['custom_field_required'][] = !empty($cf['is_required']) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
autoload/front/.DS_Store
vendored
BIN
autoload/front/.DS_Store
vendored
Binary file not shown.
@@ -177,9 +177,10 @@ class App
|
||||
'ShopOrder' => function() {
|
||||
global $mdb;
|
||||
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
|
||||
$cronJobRepo = new \Domain\CronJob\CronJobRepository( $mdb );
|
||||
return new \front\Controllers\ShopOrderController(
|
||||
$orderRepo,
|
||||
new \Domain\Order\OrderAdminService( $orderRepo )
|
||||
new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronJobRepo )
|
||||
);
|
||||
},
|
||||
'ShopProducer' => function() {
|
||||
|
||||
467
cron.php
467
cron.php
@@ -50,19 +50,26 @@ $mdb = new medoo( [
|
||||
'charset' => 'utf8'
|
||||
] );
|
||||
|
||||
$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings();
|
||||
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
|
||||
$apilo_settings = $integrationsRepository -> getSettings( 'apilo' );
|
||||
// =========================================================================
|
||||
// Auth: cron endpoint protection
|
||||
// =========================================================================
|
||||
|
||||
// Keepalive tokenu Apilo: odswiezaj token przed wygasnieciem, zeby integracja byla stale aktywna.
|
||||
if ( (int)($apilo_settings['enabled'] ?? 0) === 1 ) {
|
||||
$integrationsRepository -> apiloKeepalive( 300 );
|
||||
$apilo_settings = $integrationsRepository -> getSettings( 'apilo' );
|
||||
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
|
||||
$orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo );
|
||||
$orderAdminService->processApiloSyncQueue( 10 );
|
||||
if ( php_sapi_name() !== 'cli' )
|
||||
{
|
||||
$cron_key = isset( $config['cron_key'] ) ? $config['cron_key'] : '';
|
||||
$provided_key = isset( $_GET['key'] ) ? $_GET['key'] : '';
|
||||
|
||||
if ( $cron_key === '' || $provided_key !== $cron_key )
|
||||
{
|
||||
http_response_code( 403 );
|
||||
exit( 'Forbidden' );
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper functions (used by handlers)
|
||||
// =========================================================================
|
||||
|
||||
function parsePaczkomatAddress($input)
|
||||
{
|
||||
$pattern = '/^([\w-]+)\s+\|\s+([^,]+),\s+(\d{2}-\d{3})\s+(.+)$/';
|
||||
@@ -118,93 +125,90 @@ function getImageUrlById($id) {
|
||||
return isset($data['img']) ? $data['img'] : null;
|
||||
}
|
||||
|
||||
// pobieranie informacji o produkcie z apilo.com
|
||||
if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_settings['access-token'] )
|
||||
// =========================================================================
|
||||
// Shared dependencies
|
||||
// =========================================================================
|
||||
|
||||
$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings();
|
||||
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
|
||||
$orderRepo = new \Domain\Order\OrderRepository( $mdb );
|
||||
$cronRepo = new \Domain\CronJob\CronJobRepository( $mdb );
|
||||
$orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronRepo );
|
||||
|
||||
$processor = new \Domain\CronJob\CronJobProcessor( $cronRepo );
|
||||
|
||||
// =========================================================================
|
||||
// One-time migration: JSON queue → DB
|
||||
// =========================================================================
|
||||
|
||||
$json_queue_path = __DIR__ . '/temp/apilo-sync-queue.json';
|
||||
if ( file_exists( $json_queue_path ) )
|
||||
{
|
||||
if ( $result = $mdb -> query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) )
|
||||
$json_content = file_get_contents( $json_queue_path );
|
||||
$json_queue = $json_content ? json_decode( $json_content, true ) : [];
|
||||
|
||||
if ( is_array( $json_queue ) )
|
||||
{
|
||||
$access_token = $integrationsRepository -> apiloGetAccessToken();
|
||||
$url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/';
|
||||
$curl = curl_init( $url );
|
||||
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
|
||||
"Authorization: Bearer " . $access_token,
|
||||
"Accept: application/json"
|
||||
] );
|
||||
|
||||
$response = curl_exec( $curl );
|
||||
$responseData = json_decode( $response, true );
|
||||
|
||||
// aktualizowanie stanu magazynowego
|
||||
$mdb -> update( 'pp_shop_products', [ 'quantity' => $responseData['quantity'] ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
// aktualizowanie ceny
|
||||
$mdb -> update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithoutTax'], 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithTax'], 2 ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
|
||||
$mdb -> update( 'pp_shop_products', [ 'apilo_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
|
||||
// Czyszczenie cache produktu
|
||||
\Shared\Helpers\Helpers::clear_product_cache( (int)$result['id'] );
|
||||
|
||||
echo '<p>Zaktualizowałem dane produktu (APILO) <b>' . $result['apilo_product_name'] . ' #' . $result['id'] . '</b></p>';
|
||||
}
|
||||
}
|
||||
|
||||
// synchronizacja cen apilo.com
|
||||
if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and ( !$apilo_settings['pricelist_update_date'] or $apilo_settings['pricelist_update_date'] <= date( 'Y-m-d H:i:s', strtotime( '-1 hour', time() ) ) ) )
|
||||
{
|
||||
$access_token = $integrationsRepository -> apiloGetAccessToken();
|
||||
|
||||
$url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id'];
|
||||
|
||||
$curl = curl_init( $url );
|
||||
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $curl, CURLOPT_CUSTOMREQUEST, "GET" );
|
||||
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
|
||||
"Authorization: Bearer " . $access_token,
|
||||
"Accept: application/json",
|
||||
"Content-Type: application/json"
|
||||
] );
|
||||
|
||||
$response = curl_exec( $curl );
|
||||
$responseData = json_decode( $response, true );
|
||||
|
||||
if ( $responseData['list'] )
|
||||
{
|
||||
foreach ( $responseData['list'] as $product_price )
|
||||
foreach ( $json_queue as $task )
|
||||
{
|
||||
//aktualizowanie ceny
|
||||
if ( $product_price['customPriceWithTax'] )
|
||||
$order_id = (int)($task['order_id'] ?? 0);
|
||||
if ( $order_id <= 0 ) continue;
|
||||
|
||||
if ( !empty($task['payment']) )
|
||||
{
|
||||
$price_brutto = $product_price['customPriceWithTax'];
|
||||
$vat = $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
$price_netto = $price_brutto / ( ( 100 + $vat ) / 100 );
|
||||
|
||||
$mdb -> update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $price_brutto, 2 ) ], [ 'apilo_product_id' => $product_price['product'] ] );
|
||||
$product_id = $mdb -> get( 'pp_shop_products', 'id', [ 'apilo_product_id' => $product_price['product'] ] );
|
||||
|
||||
( new \Domain\Product\ProductRepository( $mdb ) )->updateCombinationPricesFromBase( (int)$product_id, $price_brutto, $vat, null );
|
||||
|
||||
// Czyszczenie cache produktu
|
||||
\Shared\Helpers\Helpers::clear_product_cache( (int)$product_id );
|
||||
$cronRepo->enqueue(
|
||||
\Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT,
|
||||
['order_id' => $order_id],
|
||||
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
|
||||
50
|
||||
);
|
||||
}
|
||||
if ( isset($task['status']) && $task['status'] !== null && $task['status'] !== '' )
|
||||
{
|
||||
$cronRepo->enqueue(
|
||||
\Domain\CronJob\CronJobType::APILO_SYNC_STATUS,
|
||||
['order_id' => $order_id, 'status' => (int)$task['status']],
|
||||
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
|
||||
50
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
$integrationsRepository -> saveSetting( 'apilo', 'pricelist_update_date', date( 'Y-m-d H:i:s' ) );
|
||||
echo '<p>Zaktualizowałem ceny produktów (APILO)</p>';
|
||||
|
||||
unlink( $json_queue_path );
|
||||
echo '<p>Migracja kolejki JSON → DB zakończona</p>';
|
||||
}
|
||||
|
||||
// wysyłanie zamówień do apilo
|
||||
if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) )
|
||||
{
|
||||
$orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] );
|
||||
// =========================================================================
|
||||
// Handler registration
|
||||
// =========================================================================
|
||||
|
||||
// 1. Apilo token keepalive (priorytet: krytyczny)
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_TOKEN_KEEPALIVE, function($payload) use ($integrationsRepository) {
|
||||
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
||||
if ( !(int)($apilo_settings['enabled'] ?? 0) ) return true; // skip if disabled
|
||||
|
||||
$integrationsRepository->apiloKeepalive( 300 );
|
||||
echo '<p>Apilo token keepalive</p>';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 2. Apilo send order (priorytet: wysoki)
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, function($payload) use ($mdb, $integrationsRepository, $orderAdminService, $config) {
|
||||
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
||||
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] || $apilo_settings['sync_orders_date_start'] > date('Y-m-d H:i:s') ) return true;
|
||||
|
||||
$orders = $mdb->select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] );
|
||||
if ( empty($orders) ) return true;
|
||||
|
||||
foreach ( $orders as $order )
|
||||
{
|
||||
$products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] );
|
||||
$products = $mdb->select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] );
|
||||
$productRepo = new \Domain\Product\ProductRepository( $mdb );
|
||||
$products_array = [];
|
||||
$order_message = '';
|
||||
foreach ( $products as $product )
|
||||
{
|
||||
$productRepo = new \Domain\Product\ProductRepository( $mdb );
|
||||
$sku = $productRepo->getSkuWithFallback( (int)$product['product_id'], true );
|
||||
|
||||
$products_array[] = [
|
||||
@@ -237,11 +241,9 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
$order_message .= '<hr>';
|
||||
}
|
||||
|
||||
//TODO: ostatnio był problem kiedy wiadomość miała mniej 1024 znaki ale zawierała przeniesienie tekstu '<br>' i do tego jeszcze miała emoji. Wtedy APILO tego nie przepuszczał.
|
||||
if ( strlen( $order_message ) > 850 )
|
||||
$order_message = '<p><strong>Wiadomość do zamówienia była zbyt długa. Sprawdź szczegóły w panelu sklepu</strong></p>';
|
||||
|
||||
// add transport as product
|
||||
$products_array[] = [
|
||||
'idExternal' => '',
|
||||
'ean' => null,
|
||||
@@ -256,7 +258,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
'media' => null
|
||||
];
|
||||
|
||||
// Walidacja: sprawdź czy zamówienie ma produkty z cenami > 0
|
||||
$has_priced_products = false;
|
||||
foreach ( $products_array as $pa )
|
||||
{
|
||||
@@ -270,15 +271,13 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
{
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Pominięto zamówienie - wszystkie produkty mają cenę 0.00', [ 'products' => $products_array ] );
|
||||
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Apilo: zamówienie #' . $order['id'] . ' ma zerowe ceny produktów', 'Zamówienie #' . $order['id'] . ' nie zostało wysłane do Apilo, ponieważ wszystkie produkty mają cenę 0.00 PLN. Sprawdź zamówienie w panelu sklepu.' );
|
||||
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => -2 ], [ 'id' => $order['id'] ] );
|
||||
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -2 ], [ 'id' => $order['id'] ] );
|
||||
echo '<p>Pominięto zamówienie #' . $order['id'] . ' - zerowe ceny produktów</p>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$access_token = $integrationsRepository -> apiloGetAccessToken();
|
||||
|
||||
$access_token = $integrationsRepository->apiloGetAccessToken();
|
||||
$order_date = new DateTime( $order['date_order'] );
|
||||
|
||||
$paczkomatData = parsePaczkomatAddress( $order['inpost_paczkomat'] );
|
||||
$orlenPointData = parseOrlenAddress( $order['orlen_point'] );
|
||||
|
||||
@@ -326,7 +325,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
'originalCurrency' => 'PLN',
|
||||
'originalAmountTotalWithTax' => str_replace( ',', '.', $order['summary'] ),
|
||||
'orderItems' => $products_array,
|
||||
'orderedAt' => $order_date -> format('Y-m-d\TH:i:s\Z'),
|
||||
'orderedAt' => $order_date->format('Y-m-d\TH:i:s\Z'),
|
||||
'addressCustomer' => [
|
||||
'name' => $order['client_name'] . ' ' . $order['client_surname'],
|
||||
'phone' => $order['client_phone'],
|
||||
@@ -361,7 +360,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
$postData['addressInvoice']['companyTaxNumber'] = $order['firm_nip'];
|
||||
}
|
||||
|
||||
// jeżeli paczkomat
|
||||
if ( $order['inpost_paczkomat'] )
|
||||
{
|
||||
$postData['addressDelivery']['parcelName'] = $order['inpost_paczkomat'] ? 'Paczkomat: ' . $order['inpost_paczkomat'] : null;
|
||||
@@ -381,7 +379,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
}
|
||||
}
|
||||
|
||||
// jeżeli orlen paczka
|
||||
if ( $order['orlen_point'] )
|
||||
{
|
||||
$postData['addressDelivery']['parcelName'] = $order['orlen_point'] ? 'Automat ORLEN ' . $order['orlen_point'] : null;
|
||||
@@ -399,16 +396,14 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
$postData['addressDelivery']['zipCode'] = $postalCode;
|
||||
$postData['addressDelivery']['city'] = $city;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if ( $order['paid'] )
|
||||
{
|
||||
$payment_date = new DateTime( $order['date_order'] );
|
||||
|
||||
$postData['orderPayments'][] = [
|
||||
'amount' => str_replace( ',', '.', $order['summary'] ),
|
||||
'paymentDate' => $payment_date -> format('Y-m-d\TH:i:s\Z'),
|
||||
'paymentDate' => $payment_date->format('Y-m-d\TH:i:s\Z'),
|
||||
'type' => ( new \Domain\PaymentMethod\PaymentMethodRepository( $mdb ) )->getApiloPaymentTypeId( (int)$order['payment_method_id'] )
|
||||
];
|
||||
}
|
||||
@@ -435,30 +430,29 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
|
||||
$response = json_decode( $response, true );
|
||||
|
||||
if ( $config['debug']['apilo'] )
|
||||
if ( isset($config['debug']['apilo']) && $config['debug']['apilo'] )
|
||||
{
|
||||
file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', date( 'Y-m-d H:i:s' ) . " --- SEND ORDER TO APILO\n\n", FILE_APPEND );
|
||||
file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', print_r( $postData, true ) . "\n\n", FILE_APPEND );
|
||||
file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', print_r( $response, true ) . "\n\n", FILE_APPEND );
|
||||
}
|
||||
|
||||
if ( $response['message'] == 'Order already exists' )
|
||||
if ( isset($response['message']) && $response['message'] == 'Order already exists' )
|
||||
{
|
||||
$apilo_order_id = str_replace( 'Order id: ', '', $response['description'] );
|
||||
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
|
||||
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie już istnieje w Apilo (apilo_order_id: ' . $apilo_order_id . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
|
||||
echo '<p>Zaktualizowałem id zamówienia na podstawie zamówienia apilo.com</p>';
|
||||
}
|
||||
elseif ( $response['message'] == 'Validation error' )
|
||||
elseif ( isset($response['message']) && $response['message'] == 'Validation error' )
|
||||
{
|
||||
// sprawdzanie czy błąd dotyczy duplikatu idExternal
|
||||
$is_duplicate_idexternal = false;
|
||||
if ( isset( $response['errors'] ) and is_array( $response['errors'] ) )
|
||||
if ( isset( $response['errors'] ) && is_array( $response['errors'] ) )
|
||||
{
|
||||
foreach ( $response['errors'] as $error )
|
||||
{
|
||||
if ( isset( $error['field'] ) and $error['field'] == 'idExternal' and
|
||||
( strpos( $error['message'], 'już wykorzystywana' ) !== false or
|
||||
if ( isset( $error['field'] ) && $error['field'] == 'idExternal' &&
|
||||
( strpos( $error['message'], 'już wykorzystywana' ) !== false ||
|
||||
strpos( $error['message'], 'already' ) !== false ) )
|
||||
{
|
||||
$is_duplicate_idexternal = true;
|
||||
@@ -469,7 +463,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
|
||||
if ( $is_duplicate_idexternal )
|
||||
{
|
||||
// próba pobrania zamówienia z Apilo na podstawie idExternal
|
||||
$ch_get = curl_init();
|
||||
curl_setopt( $ch_get, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/?idExternal=" . $order['id'] );
|
||||
curl_setopt( $ch_get, CURLOPT_RETURNTRANSFER, true );
|
||||
@@ -482,22 +475,16 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
|
||||
$get_response_data = json_decode( $get_response, true );
|
||||
|
||||
if ( isset( $get_response_data['list'] ) and count( $get_response_data['list'] ) > 0 )
|
||||
if ( isset( $get_response_data['list'] ) && count( $get_response_data['list'] ) > 0 )
|
||||
{
|
||||
$apilo_order_id = $get_response_data['list'][0]['id'];
|
||||
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
|
||||
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] );
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Duplikat idExternal - pobrano apilo_order_id: ' . $apilo_order_id, [ 'http_code' => $http_code_send, 'response' => $response, 'get_response' => $get_response_data ] );
|
||||
echo '<p>Zamówienie już istnieje w Apilo. Zaktualizowano ID zamówienia: ' . $apilo_order_id . '</p>';
|
||||
}
|
||||
else
|
||||
{
|
||||
echo '<pre>';
|
||||
echo print_r( $response, true );
|
||||
echo print_r( $postData, true );
|
||||
echo '</pre>';
|
||||
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd: duplikat idExternal, ale nie znaleziono zamówienia w Apilo', [ 'http_code' => $http_code_send, 'response' => $response, 'get_response' => $get_response_data ] );
|
||||
|
||||
$email_data = print_r( $response, true );
|
||||
$email_data .= print_r( $postData, true );
|
||||
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com - nie znaleziono zamówienia', $email_data );
|
||||
@@ -505,13 +492,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
}
|
||||
else
|
||||
{
|
||||
echo '<pre>';
|
||||
echo print_r( $response, true );
|
||||
echo print_r( $postData, true );
|
||||
echo '</pre>';
|
||||
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd walidacji wysyłania zamówienia do Apilo', [ 'http_code' => $http_code_send, 'response' => $response ] );
|
||||
|
||||
$email_data = print_r( $response, true );
|
||||
$email_data .= print_r( $postData, true );
|
||||
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com', $email_data );
|
||||
@@ -519,39 +500,146 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
}
|
||||
elseif ( $http_code_send >= 400 || !isset( $response['id'] ) )
|
||||
{
|
||||
// Błąd serwera lub brak ID w odpowiedzi — logujemy i pomijamy, NIE ustawiamy apilo_order_id
|
||||
// żeby zamówienie nie wpadło w nieskończoną pętlę, ustawiamy apilo_order_id na -1 (błąd)
|
||||
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['id'] ] );
|
||||
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['id'] ] );
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd wysyłania zamówienia do Apilo (HTTP ' . $http_code_send . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
|
||||
|
||||
$email_data = 'HTTP Code: ' . $http_code_send . "\n\n";
|
||||
$email_data .= print_r( $response, true );
|
||||
$email_data .= print_r( $postData, true );
|
||||
\Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia #' . $order['id'] . ' do apilo.com (HTTP ' . $http_code_send . ')', $email_data );
|
||||
|
||||
echo '<p>Błąd wysyłania zamówienia do apilo.com: ID: ' . $order['id'] . ' (HTTP ' . $http_code_send . ')</p>';
|
||||
}
|
||||
else
|
||||
{
|
||||
$mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] );
|
||||
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] );
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie wysłane do Apilo (apilo_order_id: ' . $response['id'] . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
|
||||
echo '<p>Wysłałem zamówienie do apilo.com: ID: ' . $order['id'] . ' - ' . $response['id'] . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Po wysłaniu zamówień: przetwórz kolejkę sync (płatności/statusy oczekujące na apilo_order_id)
|
||||
$orderAdminService->processApiloSyncQueue( 10 );
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3. Apilo sync payment (event-driven — enqueued by OrderAdminService)
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, function($payload) use ($mdb, $orderRepo, $orderAdminService) {
|
||||
$order_id = (int)($payload['order_id'] ?? 0);
|
||||
if ( $order_id <= 0 ) return true;
|
||||
|
||||
$order = $orderRepo->findRawById( $order_id );
|
||||
if ( !$order ) return true;
|
||||
|
||||
if ( empty($order['apilo_order_id']) ) return false; // retry — awaiting apilo_order_id
|
||||
|
||||
if ( (int)$order['paid'] !== 1 ) return true; // not paid — nothing to sync
|
||||
|
||||
return $orderAdminService->syncApiloPayment( $order );
|
||||
});
|
||||
|
||||
// 4. Apilo sync status (event-driven — enqueued by OrderAdminService)
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_STATUS, function($payload) use ($mdb, $orderRepo, $orderAdminService) {
|
||||
$order_id = (int)($payload['order_id'] ?? 0);
|
||||
$status = isset($payload['status']) ? (int)$payload['status'] : null;
|
||||
if ( $order_id <= 0 || $status === null ) return true;
|
||||
|
||||
$order = $orderRepo->findRawById( $order_id );
|
||||
if ( !$order ) return true;
|
||||
|
||||
if ( empty($order['apilo_order_id']) ) return false; // retry — awaiting apilo_order_id
|
||||
|
||||
return $orderAdminService->syncApiloStatus( $order, $status );
|
||||
});
|
||||
|
||||
// 5. Apilo product sync
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, function($payload) use ($mdb, $integrationsRepository) {
|
||||
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
||||
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_products'] || !$apilo_settings['access-token'] ) return true;
|
||||
|
||||
$stmt = $mdb->query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' );
|
||||
$result = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null;
|
||||
if ( !$result ) return true;
|
||||
|
||||
$access_token = $integrationsRepository->apiloGetAccessToken();
|
||||
$url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/';
|
||||
$curl = curl_init( $url );
|
||||
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
|
||||
"Authorization: Bearer " . $access_token,
|
||||
"Accept: application/json"
|
||||
] );
|
||||
|
||||
$response = curl_exec( $curl );
|
||||
if ( $response === false ) return false;
|
||||
|
||||
$responseData = json_decode( $response, true );
|
||||
if ( !is_array( $responseData ) || !isset( $responseData['quantity'] ) ) return false;
|
||||
|
||||
$mdb->update( 'pp_shop_products', [ 'quantity' => $responseData['quantity'] ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
$mdb->update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithoutTax'], 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithTax'], 2 ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
$mdb->update( 'pp_shop_products', [ 'apilo_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] );
|
||||
\Shared\Helpers\Helpers::clear_product_cache( (int)$result['id'] );
|
||||
|
||||
echo '<p>Zaktualizowałem dane produktu (APILO) <b>' . $result['apilo_product_name'] . ' #' . $result['id'] . '</b></p>';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 6. Apilo pricelist sync
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC, function($payload) use ($mdb, $integrationsRepository) {
|
||||
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
||||
if ( !$apilo_settings['enabled'] || !$apilo_settings['access-token'] ) return true;
|
||||
|
||||
$access_token = $integrationsRepository->apiloGetAccessToken();
|
||||
$url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id'];
|
||||
|
||||
$curl = curl_init( $url );
|
||||
curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $curl, CURLOPT_CUSTOMREQUEST, "GET" );
|
||||
curl_setopt( $curl, CURLOPT_HTTPHEADER, [
|
||||
"Authorization: Bearer " . $access_token,
|
||||
"Accept: application/json",
|
||||
"Content-Type: application/json"
|
||||
] );
|
||||
|
||||
$response = curl_exec( $curl );
|
||||
if ( $response === false ) return false;
|
||||
|
||||
$responseData = json_decode( $response, true );
|
||||
if ( !is_array( $responseData ) ) return false;
|
||||
|
||||
if ( isset($responseData['list']) && $responseData['list'] )
|
||||
{
|
||||
foreach ( $responseData['list'] as $product_price )
|
||||
{
|
||||
if ( $product_price['customPriceWithTax'] )
|
||||
{
|
||||
$price_brutto = $product_price['customPriceWithTax'];
|
||||
$vat = $mdb->get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $product_price['product'] ] );
|
||||
$price_netto = $price_brutto / ( ( 100 + $vat ) / 100 );
|
||||
|
||||
$mdb->update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $price_brutto, 2 ) ], [ 'apilo_product_id' => $product_price['product'] ] );
|
||||
$product_id = $mdb->get( 'pp_shop_products', 'id', [ 'apilo_product_id' => $product_price['product'] ] );
|
||||
|
||||
( new \Domain\Product\ProductRepository( $mdb ) )->updateCombinationPricesFromBase( (int)$product_id, $price_brutto, $vat, null );
|
||||
\Shared\Helpers\Helpers::clear_product_cache( (int)$product_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
$integrationsRepository->saveSetting( 'apilo', 'pricelist_update_date', date( 'Y-m-d H:i:s' ) );
|
||||
echo '<p>Zaktualizowałem ceny produktów (APILO)</p>';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 7. Apilo status poll
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, function($payload) use ($mdb, $integrationsRepository, $orderRepo, $orderAdminService) {
|
||||
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
||||
if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] ) return true;
|
||||
|
||||
$stmt = $mdb->query( 'SELECT id, apilo_order_id, apilo_order_status_date, number FROM pp_shop_orders WHERE apilo_order_id IS NOT NULL AND ( status != 6 AND status != 8 AND status != 9 ) AND ( apilo_order_status_date IS NULL OR apilo_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_order_status_date ASC LIMIT 5' );
|
||||
$orders = $stmt ? $stmt->fetchAll( \PDO::FETCH_ASSOC ) : [];
|
||||
|
||||
// sprawdzanie statusów zamówień w apilo.com jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane
|
||||
if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) )
|
||||
{
|
||||
$orders = $mdb -> query( 'SELECT id, apilo_order_id, apilo_order_status_date, number FROM pp_shop_orders WHERE apilo_order_id IS NOT NULL AND ( status != 6 AND status != 8 AND status != 9 ) AND ( apilo_order_status_date IS NULL OR apilo_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_order_status_date ASC LIMIT 5' ) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||
foreach ( $orders as $order )
|
||||
{
|
||||
if ( $order['apilo_order_id'] )
|
||||
{
|
||||
$access_token = $integrationsRepository -> apiloGetAccessToken();
|
||||
$access_token = $integrationsRepository->apiloGetAccessToken();
|
||||
$url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/';
|
||||
|
||||
$ch = curl_init( $url );
|
||||
@@ -565,70 +653,103 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se
|
||||
$http_code_poll = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$responseData = json_decode( $response, true );
|
||||
|
||||
if ( $responseData['id'] and $responseData['status'] )
|
||||
if ( isset($responseData['id']) && $responseData['id'] && isset($responseData['status']) && $responseData['status'] )
|
||||
{
|
||||
$shop_status_id = ( new \Domain\ShopStatus\ShopStatusRepository( $mdb ) )->getByIntegrationStatusId( 'apilo', (int)$responseData['status'] );
|
||||
|
||||
if ( $shop_status_id )
|
||||
$orderAdminService->changeStatus( (int)$order['id'], $shop_status_id, false );
|
||||
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'status_poll', (int)$order['id'], 'Status pobrany z Apilo (apilo_status: ' . $responseData['status'] . ', shop_status: ' . ($shop_status_id ?: 'brak mapowania') . ')', [ 'apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code_poll, 'response' => $responseData ] );
|
||||
\Domain\Integrations\ApiloLogger::log( $mdb, 'status_poll', (int)$order['id'], 'Status pobrany z Apilo (apilo_status: ' . $responseData['status'] . ', shop_status: ' . ($shop_status_id ? $shop_status_id : 'brak mapowania') . ')', [ 'apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code_poll, 'response' => $responseData ] );
|
||||
|
||||
$orderRepo->updateApiloStatusDate( (int)$order['id'], date( 'Y-m-d H:i:s' ) );
|
||||
echo '<p>Zaktualizowałem status zamówienia <b>' . $order['number'] . '</b></p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
/* zapisywanie historii cen produktów */
|
||||
$results = $mdb -> select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] );
|
||||
foreach ( $results as $row )
|
||||
{
|
||||
if ( $price )
|
||||
// 8. Price history
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::PRICE_HISTORY, function($payload) use ($mdb) {
|
||||
$results = $mdb->select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] );
|
||||
|
||||
foreach ( $results as $row )
|
||||
{
|
||||
$mdb -> insert( 'pp_shop_product_price_history', [
|
||||
'id_product' => $row['id'],
|
||||
'price' => $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto'],
|
||||
'date' => date( 'Y-m-d' )
|
||||
] );
|
||||
$price = $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto'];
|
||||
if ( $price )
|
||||
{
|
||||
$mdb->insert( 'pp_shop_product_price_history', [
|
||||
'id_product' => $row['id'],
|
||||
'price' => $price,
|
||||
'date' => date( 'Y-m-d' )
|
||||
] );
|
||||
}
|
||||
|
||||
$mdb->update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] );
|
||||
$mdb->delete( 'pp_shop_product_price_history', [ 'date[<=]' => date( 'Y-m-d', strtotime( '-31 days', time() ) ) ] );
|
||||
echo '<p>Zapisuję historyczną cenę dla produktu <b>#' . $row['id'] . '</b></p>';
|
||||
}
|
||||
|
||||
$mdb -> update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] );
|
||||
return true;
|
||||
});
|
||||
|
||||
$mdb -> delete( 'pp_shop_product_price_history', [ 'date[<=]' => date( 'Y-m-d', strtotime( '-31 days', time() ) ) ] );
|
||||
echo '<p>Zapisuję historyczną cenę dla produktu <b>#' . $row['id'] . '</b></p>';
|
||||
}
|
||||
|
||||
/* parsowanie zamówień m.in. pod kątem najczęściej sprzedawanych razem produktów */
|
||||
$orders = $mdb -> select( 'pp_shop_orders', 'id', [ 'parsed' => 0, 'LIMIT' => 1 ] );
|
||||
foreach ( $orders as $order )
|
||||
{
|
||||
$products = $mdb -> select( 'pp_shop_order_products', 'product_id', [ 'order_id' => $order ] );
|
||||
foreach ( $products as $product1 )
|
||||
// 9. Order analysis
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::ORDER_ANALYSIS, function($payload) use ($mdb) {
|
||||
$orders = $mdb->select( 'pp_shop_orders', 'id', [ 'parsed' => 0, 'LIMIT' => 1 ] );
|
||||
foreach ( $orders as $order )
|
||||
{
|
||||
if ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) )
|
||||
$product1 = $parent_id;
|
||||
|
||||
foreach ( $products as $product2 )
|
||||
$products = $mdb->select( 'pp_shop_order_products', 'product_id', [ 'order_id' => $order ] );
|
||||
foreach ( $products as $product1 )
|
||||
{
|
||||
if ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product2 ] ) )
|
||||
$product2 = $parent_id;
|
||||
if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) )
|
||||
$product1 = $parent_id;
|
||||
|
||||
if ( $product1 != $product2 )
|
||||
foreach ( $products as $product2 )
|
||||
{
|
||||
$intersection_id = $mdb -> query( 'SELECT * FROM pp_shop_orders_products_intersection WHERE product_1_id = :product_1_id AND product_2_id = :product_2_id OR product_1_id = :product_2_id AND product_2_id = :product_1_id', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2 ] ) -> fetch( \PDO::FETCH_ASSOC );
|
||||
if ( $intersection_id )
|
||||
if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product2 ] ) )
|
||||
$product2 = $parent_id;
|
||||
|
||||
if ( $product1 != $product2 )
|
||||
{
|
||||
$mdb -> update( 'pp_shop_orders_products_intersection', [ 'count' => $intersection_id['count'] + 1 ], [ 'id' => $intersection_id['id'] ] );
|
||||
}
|
||||
else
|
||||
{
|
||||
$mdb -> insert( 'pp_shop_orders_products_intersection', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2, 'count' => 1 ] );
|
||||
$stmt = $mdb->query( 'SELECT * FROM pp_shop_orders_products_intersection WHERE product_1_id = :product_1_id AND product_2_id = :product_2_id OR product_1_id = :product_2_id AND product_2_id = :product_1_id', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2 ] );
|
||||
$intersection_id = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null;
|
||||
if ( $intersection_id )
|
||||
{
|
||||
$mdb->update( 'pp_shop_orders_products_intersection', [ 'count' => $intersection_id['count'] + 1 ], [ 'id' => $intersection_id['id'] ] );
|
||||
}
|
||||
else
|
||||
{
|
||||
$mdb->insert( 'pp_shop_orders_products_intersection', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2, 'count' => 1 ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$mdb->update( 'pp_shop_orders', [ 'parsed' => 1 ], [ 'id' => $order ] );
|
||||
echo '<p>Parsuję zamówienie <b>#' . $order . '</b></p>';
|
||||
}
|
||||
$mdb -> update( 'pp_shop_orders', [ 'parsed' => 1 ], [ 'id' => $order ] );
|
||||
echo '<p>Parsuję zamówienie <b>#' . $order . '</b></p>';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 10. Google XML feed
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::GOOGLE_XML_FEED, function($payload) use ($mdb) {
|
||||
( new \Domain\Product\ProductRepository( $mdb ) )->generateGoogleFeedXml();
|
||||
echo '<p>Wygenerowano Google XML Feed</p>';
|
||||
return true;
|
||||
});
|
||||
|
||||
// 11. TrustMate invitation — handled by separate cron-turstmate.php (requires browser context)
|
||||
$processor->registerHandler( \Domain\CronJob\CronJobType::TRUSTMATE_INVITATION, function($payload) use ($config) {
|
||||
if ( !isset($config['trustmate']['enabled']) || !$config['trustmate']['enabled'] ) return true;
|
||||
// TrustMate requires browser context (JavaScript). Handled by cron-turstmate.php.
|
||||
return true;
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Run processor
|
||||
// =========================================================================
|
||||
|
||||
$result = $processor->run( 20 );
|
||||
|
||||
echo '<hr>';
|
||||
echo '<p><small>CronJob stats: scheduled=' . $result['scheduled'] . ', processed=' . $result['processed'] . ', succeeded=' . $result['succeeded'] . ', failed=' . $result['failed'] . ', skipped=' . $result['skipped'] . '</small></p>';
|
||||
|
||||
@@ -4,6 +4,38 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
|
||||
|
||||
---
|
||||
|
||||
## ver. 0.324 (2026-02-27) - System kolejki zadań cron
|
||||
|
||||
- **NEW**: `Domain\CronJob\CronJobType` — stałe typów zadań, priorytetów, statusów, exponential backoff
|
||||
- **NEW**: `Domain\CronJob\CronJobRepository` — CRUD na `pp_cron_jobs` + `pp_cron_schedules` (enqueue, fetchNext, markCompleted, markFailed, hasPendingJob, cleanup, recoverStuck, getDueSchedules, touchSchedule)
|
||||
- **NEW**: `Domain\CronJob\CronJobProcessor` — orkiestracja: rejestracja handlerów, tworzenie scheduled jobs, przetwarzanie kolejki z priorytetami i retry/backoff
|
||||
- **NEW**: Tabele `pp_cron_jobs` i `pp_cron_schedules` — kolejka zadań z priorytetami, exponential backoff, harmonogram cykliczny
|
||||
- **REFACTOR**: `cron.php` — zastąpienie monolitycznego ~550 linii orkiestratorem z CronJobProcessor i zarejestrowanymi handlerami
|
||||
- **REFACTOR**: `OrderAdminService::queueApiloSync()` — kolejkowanie przez `CronJobRepository::enqueue()` zamiast pliku JSON
|
||||
- **REFACTOR**: `OrderAdminService::syncApiloPayment()`, `syncApiloStatus()` — zmiana z private na public (używane przez handlery cron)
|
||||
- **REMOVED**: `OrderAdminService::processApiloSyncQueue()`, `loadApiloSyncQueue()`, `saveApiloSyncQueue()`, `apiloSyncQueuePath()`, stała `APILO_SYNC_QUEUE_FILE`
|
||||
- **NEW**: Jednorazowa migracja JSON queue → DB w cron.php (automatyczna przy pierwszym uruchomieniu)
|
||||
- **SECURITY**: `cron.php` — ochrona endpointu: wymaga `$config['cron_key']` w URL (`?key=...`) lub trybu CLI
|
||||
- **FIX**: `CronJobRepository::fetchNext()` — re-SELECT po UPDATE eliminuje race condition przy równoległych workerach
|
||||
- **FIX**: `cron.php` — null check dla `$mdb->query()` przed `->fetch()` / `->fetchAll()` (3 miejsca)
|
||||
- **FIX**: `cron.php` — walidacja odpowiedzi curl w APILO_PRODUCT_SYNC i APILO_PRICELIST_SYNC (zapobiega zapisaniu null do bazy)
|
||||
- **FIX**: DI wiring — `CronJobRepository` przekazywany do `OrderAdminService` we wszystkich 4 punktach: `admin\App`, `api\ApiRouter`, `front\App`, `cron.php`
|
||||
- **TESTS**: 41 nowych testów CronJob (CronJobTypeTest, CronJobRepositoryTest, CronJobProcessorTest)
|
||||
- **MIGRATION**: `migrations/0.324.sql`
|
||||
|
||||
---
|
||||
|
||||
## ver. 0.323 (2026-02-24) - Import zdjęć, trwałe usuwanie, fix API upload
|
||||
|
||||
- **FIX**: `IntegrationsRepository::shopproImportProduct()` — kompletny refactor importu zdjęć: walidacja HTTP response, curl timeouty, bezpieczna budowa URL, szczegółowy log do `logs/shoppro-import-debug.log` i `error_log`, czytelny komunikat z wynikiem
|
||||
- **FIX**: `ProductRepository::saveProduct()` — `saveCustomFields()` wywoływane tylko gdy klucz `custom_field_name` istnieje w danych (partial update przez API nie czyści custom fields)
|
||||
- **FIX**: `ProductRepository::delete()` — usuwanie rekordów z `pp_shop_products_custom_fields` przy kasowaniu produktu
|
||||
- **FIX**: `ProductsApiController::upload_image()` — poprawka ścieżki uploadu (`upload/` zamiast `../upload/` — api.php działa z rootu projektu)
|
||||
- **NEW**: `ProductArchiveController::delete_permanent()` — trwałe usunięcie produktu z archiwum (wraz ze zdjęciami i załącznikami)
|
||||
- **NEW**: Przycisk "Usuń trwale" w liście produktów archiwalnych z potwierdzeniem
|
||||
|
||||
---
|
||||
|
||||
## ver. 0.318 (2026-02-24) - ShopPRO export produktów + API endpoints
|
||||
|
||||
- **NEW**: `IntegrationsRepository::shopproExportProduct()` — eksport produktu do zdalnej instancji shopPRO: pola główne, tłumaczenia, custom fields, zdjęcia przez API (base64)
|
||||
|
||||
@@ -46,6 +46,17 @@ Zdjęcia produktów.
|
||||
| src | Ścieżka do pliku |
|
||||
| alt | Tekst alternatywny |
|
||||
|
||||
## pp_shop_products_custom_fields
|
||||
Dodatkowe pola produktów (custom fields).
|
||||
|
||||
| Kolumna | Opis |
|
||||
|---------|------|
|
||||
| id_additional_field | PK |
|
||||
| id_product | FK do pp_shop_products |
|
||||
| name | Nazwa pola |
|
||||
| type | Typ pola (VARCHAR 30) |
|
||||
| is_required | Czy wymagane (0/1) |
|
||||
|
||||
## pp_shop_products_categories
|
||||
Przypisanie produktów do kategorii.
|
||||
|
||||
@@ -643,3 +654,49 @@ Tlumaczenia producentow (per jezyk). FK kaskadowe ON DELETE CASCADE.
|
||||
**Aktualizacja 2026-02-15 (ver. 0.273):** modul `/admin/shop_producer` korzysta z `Domain\Producer\ProducerRepository` przez `admin\Controllers\ShopProducerController`. Usunieto legacy `admin\controls\ShopProducer` i `admin\factory\ShopProducer`. `shop\Producer` dziala jako fasada do repozytorium.
|
||||
|
||||
**Aktualizacja 2026-02-17 (ver. 0.291):** frontend `/shop_producer/*` korzysta z `Domain\Producer\ProducerRepository` przez `front\Controllers\ShopProducerController`; usunięto legacy `front\controls\ShopProducer` i `shop\Producer`.
|
||||
|
||||
## pp_cron_jobs
|
||||
Kolejka zadań cron z priorytetami i retry/backoff.
|
||||
|
||||
| Kolumna | Opis |
|
||||
|---------|------|
|
||||
| id | PK auto increment |
|
||||
| job_type | Typ zadania (VARCHAR 50) — np. apilo_send_order, price_history |
|
||||
| status | ENUM: pending, processing, completed, failed, cancelled |
|
||||
| priority | TINYINT — niższy = ważniejszy (10=krytyczny, 50=wysoki, 100=normalny, 200=niski) |
|
||||
| payload | JSON z danymi zadania (TEXT NULL) |
|
||||
| result | JSON z wynikiem (TEXT NULL) |
|
||||
| attempts | Liczba prób (SMALLINT) |
|
||||
| max_attempts | Maksymalna liczba prób (SMALLINT, domyślnie 10) |
|
||||
| last_error | Ostatni błąd (VARCHAR 500) |
|
||||
| scheduled_at | Kiedy zadanie ma być uruchomione (DATETIME) |
|
||||
| started_at | Kiedy rozpoczęto przetwarzanie (DATETIME NULL) |
|
||||
| completed_at | Kiedy zakończono (DATETIME NULL) |
|
||||
| created_at | Data utworzenia (DATETIME) |
|
||||
| updated_at | Data ostatniej modyfikacji (DATETIME, ON UPDATE) |
|
||||
|
||||
**Indeksy:** idx_status_priority_scheduled (status, priority, scheduled_at), idx_job_type, idx_status
|
||||
|
||||
**Używane w:** `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor`
|
||||
|
||||
## pp_cron_schedules
|
||||
Harmonogram cyklicznych zadań cron.
|
||||
|
||||
| Kolumna | Opis |
|
||||
|---------|------|
|
||||
| id | PK auto increment |
|
||||
| job_type | Typ zadania (VARCHAR 50, UNIQUE) |
|
||||
| interval_seconds | Interwał uruchomienia w sekundach |
|
||||
| priority | Priorytet tworzonych zadań (TINYINT) |
|
||||
| max_attempts | Maks. prób dla tworzonych zadań (SMALLINT) |
|
||||
| payload | Opcjonalny payload JSON (TEXT NULL) |
|
||||
| enabled | Czy harmonogram aktywny (TINYINT 1) |
|
||||
| last_run_at | Ostatnie uruchomienie (DATETIME NULL) |
|
||||
| next_run_at | Następne planowane uruchomienie (DATETIME NULL) |
|
||||
| created_at | Data utworzenia (DATETIME) |
|
||||
|
||||
**Indeksy:** idx_enabled_next_run (enabled, next_run_at)
|
||||
|
||||
**Używane w:** `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor`
|
||||
|
||||
**Dodano w wersji 0.324.**
|
||||
|
||||
@@ -16,6 +16,7 @@ Kazdy modul zawiera Repository (i opcjonalnie dodatkowe klasy). Konstruktor DI z
|
||||
| Category | CategoryRepository | drzewa kategorii, produkty w kategorii, Redis cache |
|
||||
| Client | ClientRepository | CRUD, auth, adresy, zamowienia |
|
||||
| Coupon | CouponRepository | kupony rabatowe, walidacja, uzycie |
|
||||
| CronJob | CronJobType, CronJobRepository, CronJobProcessor | kolejka zadan cron (DB), priorytety, retry/backoff, harmonogram |
|
||||
| Dashboard | DashboardRepository | statystyki admin, Redis cache |
|
||||
| Dictionaries | DictionariesRepository | slowniki admin |
|
||||
| Integrations | IntegrationsRepository | Apilo sync, ustawienia |
|
||||
|
||||
@@ -23,7 +23,7 @@ composer test # standard
|
||||
## Aktualny stan
|
||||
|
||||
```text
|
||||
OK (765 tests, 2153 assertions)
|
||||
OK (805 tests, 2253 assertions)
|
||||
```
|
||||
|
||||
Zweryfikowano: 2026-02-24 (ver. 0.318)
|
||||
@@ -52,6 +52,9 @@ tests/
|
||||
| | |-- Cache/CacheRepositoryTest.php
|
||||
| | |-- Category/CategoryRepositoryTest.php
|
||||
| | |-- Coupon/CouponRepositoryTest.php
|
||||
| | |-- CronJob/CronJobTypeTest.php
|
||||
| | |-- CronJob/CronJobRepositoryTest.php
|
||||
| | |-- CronJob/CronJobProcessorTest.php
|
||||
| | |-- Dictionaries/DictionariesRepositoryTest.php
|
||||
| | |-- Integrations/IntegrationsRepositoryTest.php
|
||||
| | |-- Languages/LanguagesRepositoryTest.php
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu
|
||||
1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu
|
||||
2. Poprawić htaccess, żeby w nim nie było w ogóle adresów strona wszystko z bazy.
|
||||
3. Dodać uwierzytelnienie dwuskładnikowe za pomocą aplikacji.
|
||||
@@ -1,6 +0,0 @@
|
||||
2024-01-05 08:31:37 | 157 | 2024/01/001 | 12.30 | pyziak84@gmail.com
|
||||
<pre></pre>
|
||||
|
||||
2024-01-05 08:36:54 | 157 | 2024/01/001 | 12.30 | pyziak84@gmail.com
|
||||
{"id":516}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
2024-08-20 19:28:32 | 159 | 2024/08/002 | 70.99 | pyziak84@gmail.com
|
||||
[]
|
||||
|
||||
2024-08-20 19:42:45 | 160 | 2024/08/003 | 59.99 | pyziak84@gmail.com
|
||||
{"updates":1}
|
||||
|
||||
48
migrations/0.324.sql
Normal file
48
migrations/0.324.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- System kolejki zadań cron
|
||||
-- Wersja: 0.324
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pp_cron_jobs (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
job_type VARCHAR(50) NOT NULL,
|
||||
status ENUM('pending','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending',
|
||||
priority TINYINT UNSIGNED NOT NULL DEFAULT 100,
|
||||
payload TEXT NULL,
|
||||
result TEXT NULL,
|
||||
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 10,
|
||||
last_error VARCHAR(500) NULL,
|
||||
scheduled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_status_priority_scheduled (status, priority, scheduled_at),
|
||||
INDEX idx_job_type (job_type),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pp_cron_schedules (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
job_type VARCHAR(50) NOT NULL UNIQUE,
|
||||
interval_seconds INT UNSIGNED NOT NULL,
|
||||
priority TINYINT UNSIGNED NOT NULL DEFAULT 100,
|
||||
max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 3,
|
||||
payload TEXT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_run_at DATETIME NULL,
|
||||
next_run_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_enabled_next_run (enabled, next_run_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
-- Harmonogramy zadań
|
||||
INSERT INTO pp_cron_schedules (job_type, interval_seconds, priority, max_attempts) VALUES
|
||||
('apilo_token_keepalive', 240, 10, 3),
|
||||
('apilo_send_order', 60, 40, 10),
|
||||
('apilo_product_sync', 600, 100, 3),
|
||||
('apilo_pricelist_sync', 3600, 100, 3),
|
||||
('apilo_status_poll', 600, 100, 3),
|
||||
('price_history', 86400, 100, 3),
|
||||
('order_analysis', 600, 100, 3),
|
||||
('trustmate_invitation', 600, 200, 3),
|
||||
('google_xml_feed', 3600, 200, 3);
|
||||
BIN
templates/.DS_Store
vendored
BIN
templates/.DS_Store
vendored
Binary file not shown.
301
tests/Unit/Domain/CronJob/CronJobProcessorTest.php
Normal file
301
tests/Unit/Domain/CronJob/CronJobProcessorTest.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Domain\CronJob;
|
||||
|
||||
use Domain\CronJob\CronJobProcessor;
|
||||
use Domain\CronJob\CronJobRepository;
|
||||
use Domain\CronJob\CronJobType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronJobProcessorTest extends TestCase
|
||||
{
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject|CronJobRepository */
|
||||
private $mockRepo;
|
||||
|
||||
/** @var CronJobProcessor */
|
||||
private $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mockRepo = $this->createMock(CronJobRepository::class);
|
||||
$this->processor = new CronJobProcessor($this->mockRepo);
|
||||
}
|
||||
|
||||
// --- registerHandler ---
|
||||
|
||||
public function testRegisterHandlerAndProcessJob(): void
|
||||
{
|
||||
$handlerCalled = false;
|
||||
|
||||
$this->processor->registerHandler('test_job', function ($payload) use (&$handlerCalled) {
|
||||
$handlerCalled = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 1, 'job_type' => 'test_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markCompleted')->with(1, null);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertTrue($handlerCalled);
|
||||
$this->assertSame(1, $stats['processed']);
|
||||
$this->assertSame(1, $stats['succeeded']);
|
||||
$this->assertSame(0, $stats['failed']);
|
||||
}
|
||||
|
||||
// --- processQueue ---
|
||||
|
||||
public function testProcessQueueReturnsEmptyStatsWhenNoJobs(): void
|
||||
{
|
||||
$this->mockRepo->method('fetchNext')->willReturn([]);
|
||||
|
||||
$stats = $this->processor->processQueue(5);
|
||||
|
||||
$this->assertSame(0, $stats['processed']);
|
||||
$this->assertSame(0, $stats['succeeded']);
|
||||
$this->assertSame(0, $stats['failed']);
|
||||
$this->assertSame(0, $stats['skipped']);
|
||||
}
|
||||
|
||||
public function testProcessQueueHandlerReturnsFalse(): void
|
||||
{
|
||||
$this->processor->registerHandler('fail_job', function ($payload) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 2, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markFailed')
|
||||
->with(2, 'Handler returned false', 1);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['failed']);
|
||||
$this->assertSame(0, $stats['succeeded']);
|
||||
}
|
||||
|
||||
public function testProcessQueueHandlerThrowsException(): void
|
||||
{
|
||||
$this->processor->registerHandler('error_job', function ($payload) {
|
||||
throw new \RuntimeException('Connection failed');
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 3, 'job_type' => 'error_job', 'payload' => null, 'attempts' => 2],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markFailed')
|
||||
->with(3, 'Connection failed', 2);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['failed']);
|
||||
}
|
||||
|
||||
public function testProcessQueueNoHandlerRegistered(): void
|
||||
{
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 4, 'job_type' => 'unknown_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markFailed')
|
||||
->with(4, $this->stringContains('No handler registered'), 1);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['skipped']);
|
||||
}
|
||||
|
||||
public function testProcessQueueHandlerReturnsArray(): void
|
||||
{
|
||||
$resultData = ['synced' => true, 'items' => 5];
|
||||
|
||||
$this->processor->registerHandler('array_job', function ($payload) use ($resultData) {
|
||||
return $resultData;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 5, 'job_type' => 'array_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('markCompleted')
|
||||
->with(5, $resultData);
|
||||
|
||||
$stats = $this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(1, $stats['succeeded']);
|
||||
}
|
||||
|
||||
public function testProcessQueuePassesPayloadToHandler(): void
|
||||
{
|
||||
$receivedPayload = null;
|
||||
|
||||
$this->processor->registerHandler('payload_job', function ($payload) use (&$receivedPayload) {
|
||||
$receivedPayload = $payload;
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 6, 'job_type' => 'payload_job', 'payload' => ['order_id' => 42], 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('markCompleted');
|
||||
|
||||
$this->processor->processQueue(1);
|
||||
|
||||
$this->assertSame(['order_id' => 42], $receivedPayload);
|
||||
}
|
||||
|
||||
public function testProcessQueueMultipleJobs(): void
|
||||
{
|
||||
$this->processor->registerHandler('ok_job', function ($payload) {
|
||||
return true;
|
||||
});
|
||||
$this->processor->registerHandler('fail_job', function ($payload) {
|
||||
return false;
|
||||
});
|
||||
|
||||
$this->mockRepo->method('fetchNext')->willReturn([
|
||||
['id' => 10, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1],
|
||||
['id' => 11, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1],
|
||||
['id' => 12, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1],
|
||||
]);
|
||||
|
||||
$stats = $this->processor->processQueue(10);
|
||||
|
||||
$this->assertSame(3, $stats['processed']);
|
||||
$this->assertSame(2, $stats['succeeded']);
|
||||
$this->assertSame(1, $stats['failed']);
|
||||
}
|
||||
|
||||
// --- createScheduledJobs ---
|
||||
|
||||
public function testCreateScheduledJobsFromDueSchedules(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 1,
|
||||
'job_type' => 'price_history',
|
||||
'interval_seconds' => 86400,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(false);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('enqueue')
|
||||
->with('price_history', null, 100, 3);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('touchSchedule')
|
||||
->with(1, 86400);
|
||||
|
||||
$created = $this->processor->createScheduledJobs();
|
||||
$this->assertSame(1, $created);
|
||||
}
|
||||
|
||||
public function testCreateScheduledJobsSkipsDuplicates(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 2,
|
||||
'job_type' => 'apilo_send_order',
|
||||
'interval_seconds' => 60,
|
||||
'priority' => 50,
|
||||
'max_attempts' => 10,
|
||||
'payload' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(true);
|
||||
|
||||
$this->mockRepo->expects($this->never())->method('enqueue');
|
||||
// touchSchedule still called to prevent re-checking
|
||||
$this->mockRepo->expects($this->once())->method('touchSchedule');
|
||||
|
||||
$created = $this->processor->createScheduledJobs();
|
||||
$this->assertSame(0, $created);
|
||||
}
|
||||
|
||||
public function testCreateScheduledJobsWithPayload(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 3,
|
||||
'job_type' => 'custom_job',
|
||||
'interval_seconds' => 600,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => '{"key":"value"}',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(false);
|
||||
|
||||
$this->mockRepo->expects($this->once())->method('enqueue')
|
||||
->with('custom_job', ['key' => 'value'], 100, 3);
|
||||
|
||||
$this->processor->createScheduledJobs();
|
||||
}
|
||||
|
||||
public function testCreateScheduledJobsReturnsZeroWhenNoSchedules(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([]);
|
||||
|
||||
$created = $this->processor->createScheduledJobs();
|
||||
$this->assertSame(0, $created);
|
||||
}
|
||||
|
||||
// --- run ---
|
||||
|
||||
public function testRunExecutesFullPipeline(): void
|
||||
{
|
||||
$this->mockRepo->expects($this->once())->method('recoverStuck')->with(30);
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([]);
|
||||
$this->mockRepo->method('fetchNext')->willReturn([]);
|
||||
$this->mockRepo->expects($this->once())->method('cleanup')->with(30);
|
||||
|
||||
$stats = $this->processor->run(20);
|
||||
|
||||
$this->assertArrayHasKey('scheduled', $stats);
|
||||
$this->assertArrayHasKey('processed', $stats);
|
||||
$this->assertArrayHasKey('succeeded', $stats);
|
||||
$this->assertArrayHasKey('failed', $stats);
|
||||
$this->assertArrayHasKey('skipped', $stats);
|
||||
}
|
||||
|
||||
public function testRunReturnsScheduledCount(): void
|
||||
{
|
||||
$this->mockRepo->method('getDueSchedules')->willReturn([
|
||||
[
|
||||
'id' => 1,
|
||||
'job_type' => 'job_a',
|
||||
'interval_seconds' => 60,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => null,
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'job_type' => 'job_b',
|
||||
'interval_seconds' => 120,
|
||||
'priority' => 100,
|
||||
'max_attempts' => 3,
|
||||
'payload' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockRepo->method('hasPendingJob')->willReturn(false);
|
||||
$this->mockRepo->method('fetchNext')->willReturn([]);
|
||||
|
||||
$stats = $this->processor->run(20);
|
||||
|
||||
$this->assertSame(2, $stats['scheduled']);
|
||||
}
|
||||
}
|
||||
385
tests/Unit/Domain/CronJob/CronJobRepositoryTest.php
Normal file
385
tests/Unit/Domain/CronJob/CronJobRepositoryTest.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Domain\CronJob;
|
||||
|
||||
use Domain\CronJob\CronJobRepository;
|
||||
use Domain\CronJob\CronJobType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronJobRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject|\medoo */
|
||||
private $mockDb;
|
||||
|
||||
/** @var CronJobRepository */
|
||||
private $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mockDb = $this->createMock(\medoo::class);
|
||||
$this->repo = new CronJobRepository($this->mockDb);
|
||||
}
|
||||
|
||||
// --- enqueue ---
|
||||
|
||||
public function testEnqueueInsertsJobAndReturnsId(): void
|
||||
{
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return $data['job_type'] === 'apilo_send_order'
|
||||
&& $data['status'] === 'pending'
|
||||
&& $data['priority'] === 50
|
||||
&& $data['max_attempts'] === 10
|
||||
&& isset($data['scheduled_at']);
|
||||
})
|
||||
);
|
||||
|
||||
$this->mockDb->method('id')->willReturn('42');
|
||||
|
||||
$id = $this->repo->enqueue('apilo_send_order', null, CronJobType::PRIORITY_HIGH);
|
||||
|
||||
$this->assertSame(42, $id);
|
||||
}
|
||||
|
||||
public function testEnqueueWithPayloadEncodesJson(): void
|
||||
{
|
||||
$payload = ['order_id' => 123, 'action' => 'sync'];
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) use ($payload) {
|
||||
return $data['payload'] === json_encode($payload);
|
||||
})
|
||||
);
|
||||
|
||||
$this->mockDb->method('id')->willReturn('1');
|
||||
|
||||
$this->repo->enqueue('apilo_sync_payment', $payload);
|
||||
}
|
||||
|
||||
public function testEnqueueWithoutPayloadDoesNotSetPayloadKey(): void
|
||||
{
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return !array_key_exists('payload', $data);
|
||||
})
|
||||
);
|
||||
|
||||
$this->mockDb->method('id')->willReturn('1');
|
||||
|
||||
$this->repo->enqueue('price_history');
|
||||
}
|
||||
|
||||
public function testEnqueueWithScheduledAt(): void
|
||||
{
|
||||
$scheduled = '2026-03-01 10:00:00';
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) use ($scheduled) {
|
||||
return $data['scheduled_at'] === $scheduled;
|
||||
})
|
||||
);
|
||||
|
||||
$this->mockDb->method('id')->willReturn('1');
|
||||
|
||||
$this->repo->enqueue('price_history', null, CronJobType::PRIORITY_NORMAL, 10, $scheduled);
|
||||
}
|
||||
|
||||
public function testEnqueueReturnsNullOnFailure(): void
|
||||
{
|
||||
$this->mockDb->method('insert');
|
||||
$this->mockDb->method('id')->willReturn(null);
|
||||
|
||||
$id = $this->repo->enqueue('test_job');
|
||||
$this->assertNull($id);
|
||||
}
|
||||
|
||||
// --- fetchNext ---
|
||||
|
||||
public function testFetchNextReturnsEmptyArrayWhenNoJobs(): void
|
||||
{
|
||||
$this->mockDb->method('select')->willReturn([]);
|
||||
|
||||
$result = $this->repo->fetchNext(5);
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testFetchNextUpdatesStatusToProcessing(): void
|
||||
{
|
||||
$pendingJobs = [
|
||||
['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => null],
|
||||
['id' => 2, 'job_type' => 'test2', 'status' => 'pending', 'payload' => '{"x":1}'],
|
||||
];
|
||||
$claimedJobs = [
|
||||
['id' => 1, 'job_type' => 'test', 'status' => 'processing', 'payload' => null],
|
||||
['id' => 2, 'job_type' => 'test2', 'status' => 'processing', 'payload' => '{"x":1}'],
|
||||
];
|
||||
|
||||
$this->mockDb->method('select')
|
||||
->willReturnOnConsecutiveCalls($pendingJobs, $claimedJobs);
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return $data['status'] === 'processing'
|
||||
&& isset($data['started_at']);
|
||||
}),
|
||||
$this->callback(function ($where) {
|
||||
return $where['id'] === [1, 2]
|
||||
&& $where['status'] === 'pending';
|
||||
})
|
||||
);
|
||||
|
||||
$result = $this->repo->fetchNext(5);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertSame('processing', $result[0]['status']);
|
||||
$this->assertSame('processing', $result[1]['status']);
|
||||
}
|
||||
|
||||
public function testFetchNextDecodesPayloadJson(): void
|
||||
{
|
||||
$jobs = [
|
||||
['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => '{"order_id":99}'],
|
||||
];
|
||||
|
||||
$this->mockDb->method('select')->willReturn($jobs);
|
||||
$this->mockDb->method('update');
|
||||
|
||||
$result = $this->repo->fetchNext(1);
|
||||
|
||||
$this->assertSame(['order_id' => 99], $result[0]['payload']);
|
||||
}
|
||||
|
||||
// --- markCompleted ---
|
||||
|
||||
public function testMarkCompletedUpdatesStatus(): void
|
||||
{
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return $data['status'] === 'completed'
|
||||
&& isset($data['completed_at']);
|
||||
}),
|
||||
['id' => 5]
|
||||
);
|
||||
|
||||
$this->repo->markCompleted(5);
|
||||
}
|
||||
|
||||
public function testMarkCompletedWithResult(): void
|
||||
{
|
||||
$result = ['synced' => true, 'count' => 3];
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) use ($result) {
|
||||
return $data['result'] === json_encode($result);
|
||||
}),
|
||||
['id' => 7]
|
||||
);
|
||||
|
||||
$this->repo->markCompleted(7, $result);
|
||||
}
|
||||
|
||||
// --- markFailed ---
|
||||
|
||||
public function testMarkFailedWithRetriesLeft(): void
|
||||
{
|
||||
// Job with attempts < max_attempts → reschedule with backoff
|
||||
$this->mockDb->method('get')->willReturn([
|
||||
'max_attempts' => 10,
|
||||
'attempts' => 2,
|
||||
]);
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return $data['status'] === 'pending'
|
||||
&& isset($data['scheduled_at'])
|
||||
&& isset($data['last_error']);
|
||||
}),
|
||||
['id' => 3]
|
||||
);
|
||||
|
||||
$this->repo->markFailed(3, 'Connection timeout', 2);
|
||||
}
|
||||
|
||||
public function testMarkFailedWhenMaxAttemptsReached(): void
|
||||
{
|
||||
// Job with attempts >= max_attempts → permanent failure
|
||||
$this->mockDb->method('get')->willReturn([
|
||||
'max_attempts' => 3,
|
||||
'attempts' => 3,
|
||||
]);
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return $data['status'] === 'failed'
|
||||
&& isset($data['completed_at']);
|
||||
}),
|
||||
['id' => 4]
|
||||
);
|
||||
|
||||
$this->repo->markFailed(4, 'Max retries exceeded');
|
||||
}
|
||||
|
||||
public function testMarkFailedTruncatesErrorTo500Chars(): void
|
||||
{
|
||||
$this->mockDb->method('get')->willReturn([
|
||||
'max_attempts' => 10,
|
||||
'attempts' => 1,
|
||||
]);
|
||||
|
||||
$longError = str_repeat('x', 600);
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return mb_strlen($data['last_error']) <= 500;
|
||||
}),
|
||||
['id' => 1]
|
||||
);
|
||||
|
||||
$this->repo->markFailed(1, $longError);
|
||||
}
|
||||
|
||||
// --- hasPendingJob ---
|
||||
|
||||
public function testHasPendingJobReturnsTrueWhenExists(): void
|
||||
{
|
||||
$this->mockDb->method('count')
|
||||
->with('pp_cron_jobs', $this->callback(function ($where) {
|
||||
return $where['job_type'] === 'apilo_sync_payment'
|
||||
&& $where['status'] === ['pending', 'processing'];
|
||||
}))
|
||||
->willReturn(1);
|
||||
|
||||
$this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment'));
|
||||
}
|
||||
|
||||
public function testHasPendingJobReturnsFalseWhenNone(): void
|
||||
{
|
||||
$this->mockDb->method('count')->willReturn(0);
|
||||
|
||||
$this->assertFalse($this->repo->hasPendingJob('apilo_sync_payment'));
|
||||
}
|
||||
|
||||
public function testHasPendingJobWithPayloadMatch(): void
|
||||
{
|
||||
$payload = ['order_id' => 42];
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('count')
|
||||
->with('pp_cron_jobs', $this->callback(function ($where) use ($payload) {
|
||||
return $where['payload'] === json_encode($payload);
|
||||
}))
|
||||
->willReturn(1);
|
||||
|
||||
$this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment', $payload));
|
||||
}
|
||||
|
||||
// --- cleanup ---
|
||||
|
||||
public function testCleanupDeletesOldCompletedJobs(): void
|
||||
{
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('delete')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($where) {
|
||||
return $where['status'] === ['completed', 'failed', 'cancelled']
|
||||
&& isset($where['updated_at[<]']);
|
||||
})
|
||||
);
|
||||
|
||||
$this->repo->cleanup(30);
|
||||
}
|
||||
|
||||
// --- recoverStuck ---
|
||||
|
||||
public function testRecoverStuckResetsProcessingJobs(): void
|
||||
{
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_jobs',
|
||||
$this->callback(function ($data) {
|
||||
return $data['status'] === 'pending'
|
||||
&& $data['started_at'] === null;
|
||||
}),
|
||||
$this->callback(function ($where) {
|
||||
return $where['status'] === 'processing'
|
||||
&& isset($where['started_at[<]']);
|
||||
})
|
||||
);
|
||||
|
||||
$this->repo->recoverStuck(30);
|
||||
}
|
||||
|
||||
// --- getDueSchedules ---
|
||||
|
||||
public function testGetDueSchedulesReturnsEnabledSchedules(): void
|
||||
{
|
||||
$schedules = [
|
||||
['id' => 1, 'job_type' => 'price_history', 'interval_seconds' => 86400],
|
||||
];
|
||||
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('select')
|
||||
->with(
|
||||
'pp_cron_schedules',
|
||||
'*',
|
||||
$this->callback(function ($where) {
|
||||
return $where['enabled'] === 1
|
||||
&& isset($where['OR']);
|
||||
})
|
||||
)
|
||||
->willReturn($schedules);
|
||||
|
||||
$result = $this->repo->getDueSchedules();
|
||||
$this->assertCount(1, $result);
|
||||
}
|
||||
|
||||
// --- touchSchedule ---
|
||||
|
||||
public function testTouchScheduleUpdatesTimestamps(): void
|
||||
{
|
||||
$this->mockDb->expects($this->once())
|
||||
->method('update')
|
||||
->with(
|
||||
'pp_cron_schedules',
|
||||
$this->callback(function ($data) {
|
||||
return isset($data['last_run_at'])
|
||||
&& isset($data['next_run_at']);
|
||||
}),
|
||||
['id' => 5]
|
||||
);
|
||||
|
||||
$this->repo->touchSchedule(5, 3600);
|
||||
}
|
||||
}
|
||||
97
tests/Unit/Domain/CronJob/CronJobTypeTest.php
Normal file
97
tests/Unit/Domain/CronJob/CronJobTypeTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Domain\CronJob;
|
||||
|
||||
use Domain\CronJob\CronJobType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronJobTypeTest extends TestCase
|
||||
{
|
||||
public function testAllTypesReturnsAllJobTypes(): void
|
||||
{
|
||||
$types = CronJobType::allTypes();
|
||||
|
||||
$this->assertContains('apilo_token_keepalive', $types);
|
||||
$this->assertContains('apilo_send_order', $types);
|
||||
$this->assertContains('apilo_sync_payment', $types);
|
||||
$this->assertContains('apilo_sync_status', $types);
|
||||
$this->assertContains('apilo_product_sync', $types);
|
||||
$this->assertContains('apilo_pricelist_sync', $types);
|
||||
$this->assertContains('apilo_status_poll', $types);
|
||||
$this->assertContains('price_history', $types);
|
||||
$this->assertContains('order_analysis', $types);
|
||||
$this->assertContains('trustmate_invitation', $types);
|
||||
$this->assertContains('google_xml_feed', $types);
|
||||
$this->assertCount(11, $types);
|
||||
}
|
||||
|
||||
public function testAllStatusesReturnsAllStatuses(): void
|
||||
{
|
||||
$statuses = CronJobType::allStatuses();
|
||||
|
||||
$this->assertContains('pending', $statuses);
|
||||
$this->assertContains('processing', $statuses);
|
||||
$this->assertContains('completed', $statuses);
|
||||
$this->assertContains('failed', $statuses);
|
||||
$this->assertContains('cancelled', $statuses);
|
||||
$this->assertCount(5, $statuses);
|
||||
}
|
||||
|
||||
public function testPriorityConstants(): void
|
||||
{
|
||||
$this->assertSame(10, CronJobType::PRIORITY_CRITICAL);
|
||||
$this->assertSame(40, CronJobType::PRIORITY_SEND_ORDER);
|
||||
$this->assertSame(50, CronJobType::PRIORITY_HIGH);
|
||||
$this->assertSame(100, CronJobType::PRIORITY_NORMAL);
|
||||
$this->assertSame(200, CronJobType::PRIORITY_LOW);
|
||||
|
||||
// Lower value = higher priority
|
||||
$this->assertLessThan(CronJobType::PRIORITY_SEND_ORDER, CronJobType::PRIORITY_CRITICAL);
|
||||
$this->assertLessThan(CronJobType::PRIORITY_HIGH, CronJobType::PRIORITY_SEND_ORDER);
|
||||
$this->assertLessThan(CronJobType::PRIORITY_NORMAL, CronJobType::PRIORITY_HIGH);
|
||||
$this->assertLessThan(CronJobType::PRIORITY_LOW, CronJobType::PRIORITY_NORMAL);
|
||||
}
|
||||
|
||||
public function testCalculateBackoffExponential(): void
|
||||
{
|
||||
// Attempt 1: 60s
|
||||
$this->assertSame(60, CronJobType::calculateBackoff(1));
|
||||
// Attempt 2: 120s
|
||||
$this->assertSame(120, CronJobType::calculateBackoff(2));
|
||||
// Attempt 3: 240s
|
||||
$this->assertSame(240, CronJobType::calculateBackoff(3));
|
||||
// Attempt 4: 480s
|
||||
$this->assertSame(480, CronJobType::calculateBackoff(4));
|
||||
}
|
||||
|
||||
public function testCalculateBackoffCapsAtMax(): void
|
||||
{
|
||||
// Very high attempt should cap at MAX_BACKOFF_SECONDS (3600)
|
||||
$this->assertSame(3600, CronJobType::calculateBackoff(10));
|
||||
$this->assertSame(3600, CronJobType::calculateBackoff(20));
|
||||
}
|
||||
|
||||
public function testJobTypeConstantsMatchStrings(): void
|
||||
{
|
||||
$this->assertSame('apilo_token_keepalive', CronJobType::APILO_TOKEN_KEEPALIVE);
|
||||
$this->assertSame('apilo_send_order', CronJobType::APILO_SEND_ORDER);
|
||||
$this->assertSame('apilo_sync_payment', CronJobType::APILO_SYNC_PAYMENT);
|
||||
$this->assertSame('apilo_sync_status', CronJobType::APILO_SYNC_STATUS);
|
||||
$this->assertSame('apilo_product_sync', CronJobType::APILO_PRODUCT_SYNC);
|
||||
$this->assertSame('apilo_pricelist_sync', CronJobType::APILO_PRICELIST_SYNC);
|
||||
$this->assertSame('apilo_status_poll', CronJobType::APILO_STATUS_POLL);
|
||||
$this->assertSame('price_history', CronJobType::PRICE_HISTORY);
|
||||
$this->assertSame('order_analysis', CronJobType::ORDER_ANALYSIS);
|
||||
$this->assertSame('trustmate_invitation', CronJobType::TRUSTMATE_INVITATION);
|
||||
$this->assertSame('google_xml_feed', CronJobType::GOOGLE_XML_FEED);
|
||||
}
|
||||
|
||||
public function testStatusConstantsMatchStrings(): void
|
||||
{
|
||||
$this->assertSame('pending', CronJobType::STATUS_PENDING);
|
||||
$this->assertSame('processing', CronJobType::STATUS_PROCESSING);
|
||||
$this->assertSame('completed', CronJobType::STATUS_COMPLETED);
|
||||
$this->assertSame('failed', CronJobType::STATUS_FAILED);
|
||||
$this->assertSame('cancelled', CronJobType::STATUS_CANCELLED);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use Domain\Order\OrderRepository;
|
||||
use Domain\Product\ProductRepository;
|
||||
use Domain\Settings\SettingsRepository;
|
||||
use Domain\Transport\TransportRepository;
|
||||
use Domain\CronJob\CronJobRepository;
|
||||
use Domain\CronJob\CronJobType;
|
||||
|
||||
class OrderAdminServiceTest extends TestCase
|
||||
{
|
||||
@@ -229,108 +231,14 @@ class OrderAdminServiceTest extends TestCase
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// processApiloSyncQueue — awaiting apilo_order_id
|
||||
// queueApiloSync — DB-based via CronJobRepository
|
||||
// =========================================================================
|
||||
|
||||
private function getQueuePath(): string
|
||||
public function testConstructorAcceptsCronJobRepo(): void
|
||||
{
|
||||
// Musi odpowiadać ścieżce w OrderAdminService::apiloSyncQueuePath()
|
||||
// dirname(autoload/Domain/Order/, 2) = autoload/
|
||||
return dirname(__DIR__, 4) . '/autoload/temp/apilo-sync-queue.json';
|
||||
}
|
||||
|
||||
private function writeQueue(array $queue): void
|
||||
{
|
||||
$path = $this->getQueuePath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function readQueue(): array
|
||||
{
|
||||
$path = $this->getQueuePath();
|
||||
if (!file_exists($path)) return [];
|
||||
$content = file_get_contents($path);
|
||||
return $content ? json_decode($content, true) : [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$path = $this->getQueuePath();
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testProcessApiloSyncQueueKeepsTaskWhenApiloOrderIdIsNull(): void
|
||||
{
|
||||
// Zamówienie bez apilo_order_id — task powinien zostać w kolejce
|
||||
$this->writeQueue([
|
||||
'42' => [
|
||||
'order_id' => 42,
|
||||
'payment' => 1,
|
||||
'status' => null,
|
||||
'attempts' => 0,
|
||||
'last_error' => 'awaiting_apilo_order',
|
||||
'updated_at' => '2026-01-01 00:00:00',
|
||||
],
|
||||
]);
|
||||
|
||||
$orderRepo = $this->createMock(OrderRepository::class);
|
||||
$orderRepo->method('findRawById')
|
||||
->with(42)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'apilo_order_id' => null,
|
||||
'paid' => 1,
|
||||
'summary' => '100.00',
|
||||
]);
|
||||
|
||||
$service = new OrderAdminService($orderRepo);
|
||||
$processed = $service->processApiloSyncQueue(10);
|
||||
|
||||
$this->assertSame(1, $processed);
|
||||
|
||||
$queue = $this->readQueue();
|
||||
$this->assertArrayHasKey('42', $queue);
|
||||
$this->assertSame('awaiting_apilo_order', $queue['42']['last_error']);
|
||||
$this->assertSame(1, $queue['42']['attempts']);
|
||||
}
|
||||
|
||||
public function testProcessApiloSyncQueueRemovesTaskAfterMaxAttempts(): void
|
||||
{
|
||||
// Task z 49 próbami — limit to 50, więc powinien zostać usunięty
|
||||
$this->writeQueue([
|
||||
'42' => [
|
||||
'order_id' => 42,
|
||||
'payment' => 1,
|
||||
'status' => null,
|
||||
'attempts' => 49,
|
||||
'last_error' => 'awaiting_apilo_order',
|
||||
'updated_at' => '2026-01-01 00:00:00',
|
||||
],
|
||||
]);
|
||||
|
||||
$orderRepo = $this->createMock(OrderRepository::class);
|
||||
$orderRepo->method('findRawById')
|
||||
->with(42)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'apilo_order_id' => null,
|
||||
'paid' => 1,
|
||||
'summary' => '100.00',
|
||||
]);
|
||||
|
||||
$service = new OrderAdminService($orderRepo);
|
||||
$processed = $service->processApiloSyncQueue(10);
|
||||
|
||||
$this->assertSame(1, $processed);
|
||||
|
||||
$queue = $this->readQueue();
|
||||
$this->assertArrayNotHasKey('42', $queue);
|
||||
$cronJobRepo = $this->createMock(CronJobRepository::class);
|
||||
$service = new OrderAdminService($orderRepo, null, null, null, $cronJobRepo);
|
||||
$this->assertInstanceOf(OrderAdminService::class, $service);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
updates/0.30/ver_0.321.zip
Normal file
BIN
updates/0.30/ver_0.321.zip
Normal file
Binary file not shown.
23
updates/0.30/ver_0.321_manifest.json
Normal file
23
updates/0.30/ver_0.321_manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"changelog": "NEW - API: obsługa custom_fields w create/update produktu",
|
||||
"version": "0.321",
|
||||
"files": {
|
||||
"added": [
|
||||
|
||||
],
|
||||
"deleted": [
|
||||
|
||||
],
|
||||
"modified": [
|
||||
"autoload/api/Controllers/ProductsApiController.php"
|
||||
]
|
||||
},
|
||||
"checksum_zip": "sha256:a04ac9975618bc3b21d80a8e449f98e5bc825ce49a37142e1e621a2ef34a19f1",
|
||||
"sql": [
|
||||
|
||||
],
|
||||
"date": "2026-02-24",
|
||||
"directories_deleted": [
|
||||
|
||||
]
|
||||
}
|
||||
BIN
updates/0.30/ver_0.322.zip
Normal file
BIN
updates/0.30/ver_0.322.zip
Normal file
Binary file not shown.
24
updates/0.30/ver_0.322_manifest.json
Normal file
24
updates/0.30/ver_0.322_manifest.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"changelog": "FIX - custom_fields: jawne mapowanie kluczy w ProductRepository, spójne !empty w ProductsApiController",
|
||||
"version": "0.322",
|
||||
"files": {
|
||||
"added": [
|
||||
|
||||
],
|
||||
"deleted": [
|
||||
|
||||
],
|
||||
"modified": [
|
||||
"autoload/Domain/Product/ProductRepository.php",
|
||||
"autoload/api/Controllers/ProductsApiController.php"
|
||||
]
|
||||
},
|
||||
"checksum_zip": "sha256:7c6e04cc393fdcd94e6fc9d2ea7a85f9c078e4beb9075d490be90675e4f6eae7",
|
||||
"sql": [
|
||||
|
||||
],
|
||||
"date": "2026-02-24",
|
||||
"directories_deleted": [
|
||||
|
||||
]
|
||||
}
|
||||
BIN
updates/0.30/ver_0.323.zip
Normal file
BIN
updates/0.30/ver_0.323.zip
Normal file
Binary file not shown.
26
updates/0.30/ver_0.323_manifest.json
Normal file
26
updates/0.30/ver_0.323_manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"changelog": "FIX - refactor importu zdjec w shopPRO import (walidacja HTTP, curl timeouty, logi), FIX - saveCustomFields tylko przy jawnym podaniu, FIX - delete() czysci custom_fields, FIX - sciezka upload w API, NEW - trwale usuwanie produktow z archiwum",
|
||||
"version": "0.323",
|
||||
"files": {
|
||||
"added": [
|
||||
|
||||
],
|
||||
"deleted": [
|
||||
|
||||
],
|
||||
"modified": [
|
||||
"autoload/Domain/Integrations/IntegrationsRepository.php",
|
||||
"autoload/Domain/Product/ProductRepository.php",
|
||||
"autoload/admin/Controllers/ProductArchiveController.php",
|
||||
"autoload/api/Controllers/ProductsApiController.php"
|
||||
]
|
||||
},
|
||||
"checksum_zip": "sha256:8e779c9b2ad63bbfb478692d20a5327eeb9dc51f83d5da0ec6efe1a61917e8a5",
|
||||
"sql": [
|
||||
|
||||
],
|
||||
"date": "2026-02-24",
|
||||
"directories_deleted": [
|
||||
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
<?
|
||||
$current_ver = 321;
|
||||
$current_ver = 323;
|
||||
|
||||
for ($i = 1; $i <= $current_ver; $i++)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user