Phase 1 complete (2/2 plans): - Plan 01-01: Extract AllegroTokenManager — OAuth token logic centralized from 4 classes into dedicated manager class - Plan 01-02: Extract StringHelper — nullableString/normalizeDateTime/ normalizeColorHex extracted from 15+ classes into App\Core\Support\StringHelper; removed 19 duplicate private methods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
385 lines
13 KiB
PHP
385 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Settings;
|
|
|
|
use App\Core\Support\StringHelper;
|
|
use App\Modules\Orders\OrdersRepository;
|
|
use PDO;
|
|
use Throwable;
|
|
|
|
final class ShopproPaymentStatusSyncService
|
|
{
|
|
private const PAID_STATUS = 2;
|
|
private const UNPAID_STATUS = 0;
|
|
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
private const DEFAULT_FINAL_STATUS_CODES = [
|
|
'wyslane',
|
|
'zrealizowane',
|
|
'anulowane',
|
|
'cancelled',
|
|
'canceled',
|
|
'delivered',
|
|
'returned',
|
|
'shipped',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly ShopproIntegrationsRepository $integrations,
|
|
private readonly ShopproApiClient $apiClient,
|
|
private readonly OrdersRepository $orders,
|
|
private readonly PDO $pdo
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function sync(array $options = []): array
|
|
{
|
|
$perIntegrationLimit = max(1, min(500, (int) ($options['per_integration_limit'] ?? 100)));
|
|
$result = [
|
|
'ok' => true,
|
|
'checked_integrations' => 0,
|
|
'processed_orders' => 0,
|
|
'updated_orders' => 0,
|
|
'skipped_orders' => 0,
|
|
'failed_orders' => 0,
|
|
'errors' => [],
|
|
];
|
|
|
|
foreach ($this->integrations->listIntegrations() as $integration) {
|
|
$integrationId = (int) ($integration['id'] ?? 0);
|
|
if ($integrationId <= 0 || empty($integration['is_active']) || empty($integration['has_api_key'])) {
|
|
continue;
|
|
}
|
|
|
|
$baseUrl = trim((string) ($integration['base_url'] ?? ''));
|
|
$apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
|
|
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
|
|
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
|
|
continue;
|
|
}
|
|
|
|
$result['checked_integrations'] = (int) $result['checked_integrations'] + 1;
|
|
$watchedStatuses = $this->resolveWatchedStatusCodes($integration);
|
|
$orders = $this->findCandidateOrders($integrationId, $watchedStatuses, $perIntegrationLimit);
|
|
|
|
foreach ($orders as $order) {
|
|
$result['processed_orders'] = (int) $result['processed_orders'] + 1;
|
|
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
|
|
if ($sourceOrderId === '') {
|
|
$result['skipped_orders'] = (int) $result['skipped_orders'] + 1;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$updated = $this->syncSingleOrderPayment(
|
|
$integrationId,
|
|
$baseUrl,
|
|
$apiKey,
|
|
$timeout,
|
|
$order
|
|
);
|
|
if ($updated) {
|
|
$result['updated_orders'] = (int) $result['updated_orders'] + 1;
|
|
} else {
|
|
$result['skipped_orders'] = (int) $result['skipped_orders'] + 1;
|
|
}
|
|
} catch (Throwable $exception) {
|
|
$result['failed_orders'] = (int) $result['failed_orders'] + 1;
|
|
$errors = is_array($result['errors']) ? $result['errors'] : [];
|
|
if (count($errors) < 20) {
|
|
$errors[] = [
|
|
'integration_id' => $integrationId,
|
|
'order_id' => (int) ($order['id'] ?? 0),
|
|
'source_order_id' => $sourceOrderId,
|
|
'error' => $exception->getMessage(),
|
|
];
|
|
}
|
|
$result['errors'] = $errors;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $integration
|
|
* @return array<int, string>
|
|
*/
|
|
private function resolveWatchedStatusCodes(array $integration): array
|
|
{
|
|
$rawCodes = $integration['payment_sync_status_codes'] ?? [];
|
|
if (!is_array($rawCodes)) {
|
|
return [];
|
|
}
|
|
|
|
$result = [];
|
|
$seen = [];
|
|
foreach ($rawCodes as $rawCode) {
|
|
$code = strtolower(trim((string) $rawCode));
|
|
if ($code === '' || isset($seen[$code])) {
|
|
continue;
|
|
}
|
|
$seen[$code] = true;
|
|
$result[] = $code;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $watchedStatuses
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function findCandidateOrders(int $integrationId, array $watchedStatuses, int $limit): array
|
|
{
|
|
$where = [
|
|
'source = :source',
|
|
'integration_id = :integration_id',
|
|
'source_order_id IS NOT NULL',
|
|
'source_order_id <> ""',
|
|
'(payment_status IS NULL OR payment_status <> :paid_status)',
|
|
];
|
|
$params = [
|
|
'source' => 'shoppro',
|
|
'integration_id' => $integrationId,
|
|
'paid_status' => self::PAID_STATUS,
|
|
];
|
|
|
|
$statusPlaceholders = [];
|
|
$statusCodes = $watchedStatuses !== [] ? $watchedStatuses : self::DEFAULT_FINAL_STATUS_CODES;
|
|
foreach ($statusCodes as $index => $statusCode) {
|
|
$placeholder = ':status_' . $index;
|
|
$statusPlaceholders[] = $placeholder;
|
|
$params['status_' . $index] = strtolower($statusCode);
|
|
}
|
|
|
|
if ($watchedStatuses !== []) {
|
|
$where[] = 'LOWER(COALESCE(external_status_id, "")) IN (' . implode(', ', $statusPlaceholders) . ')';
|
|
} else {
|
|
$where[] = 'LOWER(COALESCE(external_status_id, "")) NOT IN (' . implode(', ', $statusPlaceholders) . ')';
|
|
}
|
|
|
|
$sql = 'SELECT id, source_order_id, payment_status, total_paid, total_with_tax, currency, external_payment_type_id
|
|
FROM orders
|
|
WHERE ' . implode(' AND ', $where) . '
|
|
ORDER BY source_updated_at DESC, id DESC
|
|
LIMIT :limit';
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($params as $key => $value) {
|
|
if (is_int($value)) {
|
|
$stmt->bindValue(':' . $key, $value, PDO::PARAM_INT);
|
|
continue;
|
|
}
|
|
$stmt->bindValue(':' . $key, $value);
|
|
}
|
|
$stmt->bindValue(':limit', max(1, min(1000, $limit)), PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
|
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
return is_array($rows) ? $rows : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $order
|
|
*/
|
|
private function syncSingleOrderPayment(
|
|
int $integrationId,
|
|
string $baseUrl,
|
|
string $apiKey,
|
|
int $timeout,
|
|
array $order
|
|
): bool {
|
|
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
|
|
if ($sourceOrderId === '') {
|
|
return false;
|
|
}
|
|
|
|
$details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId);
|
|
if (($details['ok'] ?? false) !== true || !is_array($details['order'] ?? null)) {
|
|
throw new \RuntimeException((string) ($details['message'] ?? 'Blad pobierania szczegolow zamowienia.'));
|
|
}
|
|
|
|
$payload = (array) $details['order'];
|
|
$isPaid = $this->resolvePaidFlag($payload);
|
|
if ($isPaid === null) {
|
|
return false;
|
|
}
|
|
|
|
$newPaymentStatus = $isPaid ? self::PAID_STATUS : self::UNPAID_STATUS;
|
|
$existingTotalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null;
|
|
$newTotalPaid = $isPaid
|
|
? $this->resolvePaidAmount($payload, $existingTotalWithTax)
|
|
: 0.0;
|
|
$existingPaymentStatus = isset($order['payment_status']) ? (int) $order['payment_status'] : null;
|
|
$existingTotalPaid = $order['total_paid'] !== null ? (float) $order['total_paid'] : null;
|
|
$paymentMethod = StringHelper::nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
|
|
$paymentDate = StringHelper::normalizeDateTime((string) ($payload['payment_date'] ?? ''));
|
|
$sourceUpdatedAt = StringHelper::normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
|
|
|
|
if (
|
|
$existingPaymentStatus === $newPaymentStatus
|
|
&& $this->floatsEqual($existingTotalPaid, $newTotalPaid)
|
|
&& $paymentMethod === StringHelper::nullableString((string) ($order['external_payment_type_id'] ?? ''))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
$orderId = (int) ($order['id'] ?? 0);
|
|
if ($orderId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$this->pdo->beginTransaction();
|
|
try {
|
|
$this->updateOrderPaymentColumns($orderId, $newPaymentStatus, $newTotalPaid, $paymentMethod, $sourceUpdatedAt);
|
|
$this->replaceOrderPaymentRow($orderId, $paymentMethod, $paymentDate, $newTotalPaid, (string) ($order['currency'] ?? 'PLN'), $isPaid);
|
|
$this->pdo->commit();
|
|
} catch (Throwable $exception) {
|
|
if ($this->pdo->inTransaction()) {
|
|
$this->pdo->rollBack();
|
|
}
|
|
throw $exception;
|
|
}
|
|
|
|
$summary = $isPaid
|
|
? 'shopPRO: zamowienie oznaczone jako oplacone'
|
|
: 'shopPRO: zamowienie oznaczone jako nieoplacone';
|
|
$this->orders->recordActivity(
|
|
$orderId,
|
|
'payment',
|
|
$summary,
|
|
[
|
|
'integration_id' => $integrationId,
|
|
'source_order_id' => $sourceOrderId,
|
|
'old_payment_status' => $existingPaymentStatus,
|
|
'new_payment_status' => $newPaymentStatus,
|
|
'old_total_paid' => $existingTotalPaid,
|
|
'new_total_paid' => $newTotalPaid,
|
|
],
|
|
'sync',
|
|
'shopPRO'
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
private function updateOrderPaymentColumns(
|
|
int $orderId,
|
|
int $paymentStatus,
|
|
?float $totalPaid,
|
|
?string $paymentMethod,
|
|
?string $sourceUpdatedAt
|
|
): void {
|
|
$sql = 'UPDATE orders
|
|
SET payment_status = :payment_status,
|
|
total_paid = :total_paid,
|
|
external_payment_type_id = :external_payment_type_id,
|
|
fetched_at = NOW(),
|
|
updated_at = NOW()';
|
|
$params = [
|
|
'id' => $orderId,
|
|
'payment_status' => $paymentStatus,
|
|
'total_paid' => $totalPaid,
|
|
'external_payment_type_id' => $paymentMethod,
|
|
];
|
|
if ($sourceUpdatedAt !== null) {
|
|
$sql .= ', source_updated_at = :source_updated_at';
|
|
$params['source_updated_at'] = $sourceUpdatedAt;
|
|
}
|
|
$sql .= ' WHERE id = :id';
|
|
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
}
|
|
|
|
private function replaceOrderPaymentRow(
|
|
int $orderId,
|
|
?string $paymentMethod,
|
|
?string $paymentDate,
|
|
?float $amount,
|
|
string $currency,
|
|
bool $isPaid
|
|
): void {
|
|
$deleteStmt = $this->pdo->prepare('DELETE FROM order_payments WHERE order_id = :order_id');
|
|
$deleteStmt->execute(['order_id' => $orderId]);
|
|
|
|
$insertStmt = $this->pdo->prepare(
|
|
'INSERT INTO order_payments (
|
|
order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json
|
|
) VALUES (
|
|
:order_id, NULL, NULL, :payment_type_id, :payment_date, :amount, :currency, :comment, NULL
|
|
)'
|
|
);
|
|
$insertStmt->execute([
|
|
'order_id' => $orderId,
|
|
'payment_type_id' => $paymentMethod ?? 'unknown',
|
|
'payment_date' => $paymentDate,
|
|
'amount' => $amount,
|
|
'currency' => trim($currency) !== '' ? strtoupper($currency) : 'PLN',
|
|
'comment' => $isPaid ? 'paid' : 'unpaid',
|
|
]);
|
|
}
|
|
|
|
private function resolvePaidFlag(array $payload): ?bool
|
|
{
|
|
$raw = $payload['paid'] ?? $payload['is_paid'] ?? null;
|
|
if ($raw === null) {
|
|
return null;
|
|
}
|
|
if (is_bool($raw)) {
|
|
return $raw;
|
|
}
|
|
|
|
$value = strtolower(trim((string) $raw));
|
|
if (in_array($value, ['1', 'true', 'yes', 'paid'], true)) {
|
|
return true;
|
|
}
|
|
if (in_array($value, ['0', 'false', 'no', 'unpaid'], true)) {
|
|
return false;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolvePaidAmount(array $payload, ?float $fallbackGross): ?float
|
|
{
|
|
$value = $payload['total_paid'] ?? null;
|
|
if ($value !== null && is_numeric((string) $value)) {
|
|
return (float) $value;
|
|
}
|
|
|
|
$grossCandidates = [
|
|
$payload['total_gross'] ?? null,
|
|
$payload['total_with_tax'] ?? null,
|
|
$payload['summary']['total'] ?? null,
|
|
$payload['summary'] ?? null,
|
|
];
|
|
foreach ($grossCandidates as $candidate) {
|
|
if ($candidate !== null && is_numeric((string) $candidate)) {
|
|
return (float) $candidate;
|
|
}
|
|
}
|
|
|
|
return $fallbackGross;
|
|
}
|
|
|
|
private function floatsEqual(?float $left, ?float $right): bool
|
|
{
|
|
if ($left === null && $right === null) {
|
|
return true;
|
|
}
|
|
if ($left === null || $right === null) {
|
|
return false;
|
|
}
|
|
|
|
return abs($left - $right) < 0.00001;
|
|
}
|
|
}
|