update
This commit is contained in:
@@ -219,7 +219,12 @@ final class Application
|
||||
private function maybeRunCronOnWeb(Request $request): void
|
||||
{
|
||||
$path = $request->path();
|
||||
if ($path === '/health' || str_starts_with($path, '/assets/')) {
|
||||
if (
|
||||
$path === '/health'
|
||||
|| $path === '/cron'
|
||||
|| str_starts_with($path, '/cron/')
|
||||
|| str_starts_with($path, '/assets/')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ use Throwable;
|
||||
|
||||
final class AutomationController
|
||||
{
|
||||
private const HISTORY_PER_PAGE = 25;
|
||||
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.created', 'shipment.status_changed'];
|
||||
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status'];
|
||||
private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt', 'update_shipment_status'];
|
||||
private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt', 'update_shipment_status', 'update_order_status'];
|
||||
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
|
||||
private const ALLOWED_RECEIPT_ISSUE_DATE_MODES = ['today', 'order_date', 'payment_date'];
|
||||
private const ALLOWED_RECEIPT_DUPLICATE_POLICIES = ['skip_if_exists', 'allow_duplicates'];
|
||||
@@ -36,6 +37,7 @@ final class AutomationController
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly AutomationRepository $repository,
|
||||
private readonly AutomationExecutionLogRepository $executionLogs,
|
||||
private readonly ReceiptConfigRepository $receiptConfigs
|
||||
) {
|
||||
}
|
||||
@@ -43,6 +45,15 @@ final class AutomationController
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$rules = $this->repository->findAll();
|
||||
$historyFilters = $this->extractHistoryFilters($request);
|
||||
$historyPage = max(1, (int) $request->input('history_page', 1));
|
||||
$historyTotal = $this->executionLogs->count($historyFilters);
|
||||
$historyTotalPages = max(1, (int) ceil($historyTotal / self::HISTORY_PER_PAGE));
|
||||
if ($historyPage > $historyTotalPages) {
|
||||
$historyPage = $historyTotalPages;
|
||||
}
|
||||
$historyEntries = $this->executionLogs->paginate($historyFilters, $historyPage, self::HISTORY_PER_PAGE);
|
||||
$activeTab = $this->resolveActiveTab($request, $historyFilters);
|
||||
|
||||
$html = $this->template->render('automation/index', [
|
||||
'title' => 'Zadania automatyczne',
|
||||
@@ -51,6 +62,17 @@ final class AutomationController
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'rules' => $rules,
|
||||
'activeTab' => $activeTab,
|
||||
'historyEntries' => $historyEntries,
|
||||
'historyFilters' => $historyFilters,
|
||||
'historyEventTypes' => array_values(array_unique(array_merge(self::ALLOWED_EVENTS, $this->executionLogs->listEventTypes()))),
|
||||
'historyRuleOptions' => $this->repository->listRuleOptions(),
|
||||
'historyPagination' => [
|
||||
'page' => $historyPage,
|
||||
'per_page' => self::HISTORY_PER_PAGE,
|
||||
'total' => $historyTotal,
|
||||
'total_pages' => $historyTotalPages,
|
||||
],
|
||||
'successMessage' => Flash::get('settings.automation.success', ''),
|
||||
'errorMessage' => Flash::get('settings.automation.error', ''),
|
||||
], 'layouts/app');
|
||||
@@ -225,6 +247,7 @@ final class AutomationController
|
||||
'receiptIssueDateModes' => self::ALLOWED_RECEIPT_ISSUE_DATE_MODES,
|
||||
'receiptDuplicatePolicies' => self::ALLOWED_RECEIPT_DUPLICATE_POLICIES,
|
||||
'shipmentStatusOptions' => self::SHIPMENT_STATUS_OPTIONS,
|
||||
'orderStatusOptions' => $this->repository->listActiveOrderStatuses(),
|
||||
'errorMessage' => Flash::get('settings.automation.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
@@ -425,6 +448,24 @@ final class AutomationController
|
||||
return ['status_key' => $statusKey];
|
||||
}
|
||||
|
||||
if ($type === 'update_order_status') {
|
||||
$statusCode = trim((string) ($action['order_status_code'] ?? ''));
|
||||
if ($statusCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$availableCodes = array_map(
|
||||
static fn (array $row): string => trim((string) ($row['code'] ?? '')),
|
||||
$this->repository->listActiveOrderStatuses()
|
||||
);
|
||||
|
||||
if (!in_array($statusCode, $availableCodes, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['status_code' => $statusCode];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -452,4 +493,53 @@ final class AutomationController
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string}
|
||||
*/
|
||||
private function extractHistoryFilters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'event_type' => trim((string) $request->input('history_event_type', '')),
|
||||
'execution_status' => trim((string) $request->input('history_status', '')),
|
||||
'rule_id' => max(0, (int) $request->input('history_rule_id', 0)),
|
||||
'order_id' => max(0, (int) $request->input('history_order_id', 0)),
|
||||
'date_from' => trim((string) $request->input('history_date_from', '')),
|
||||
'date_to' => trim((string) $request->input('history_date_to', '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $historyFilters
|
||||
*/
|
||||
private function resolveActiveTab(Request $request, array $historyFilters): string
|
||||
{
|
||||
$activeTab = trim((string) $request->input('tab', 'settings'));
|
||||
if ($activeTab === 'history') {
|
||||
return 'history';
|
||||
}
|
||||
|
||||
if ((int) $request->input('history_page', 0) > 1) {
|
||||
return 'history';
|
||||
}
|
||||
|
||||
if ($this->hasHistoryFilters($historyFilters)) {
|
||||
return 'history';
|
||||
}
|
||||
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $historyFilters
|
||||
*/
|
||||
private function hasHistoryFilters(array $historyFilters): bool
|
||||
{
|
||||
return $historyFilters['event_type'] !== ''
|
||||
|| $historyFilters['execution_status'] !== ''
|
||||
|| $historyFilters['rule_id'] > 0
|
||||
|| $historyFilters['order_id'] > 0
|
||||
|| $historyFilters['date_from'] !== ''
|
||||
|| $historyFilters['date_to'] !== '';
|
||||
}
|
||||
}
|
||||
|
||||
209
src/Modules/Automation/AutomationExecutionLogRepository.php
Normal file
209
src/Modules/Automation/AutomationExecutionLogRepository.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Automation;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AutomationExecutionLogRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(array $data): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO automation_execution_logs (
|
||||
event_type,
|
||||
rule_id,
|
||||
rule_name,
|
||||
order_id,
|
||||
execution_status,
|
||||
result_message,
|
||||
context_json,
|
||||
executed_at,
|
||||
created_at
|
||||
) VALUES (
|
||||
:event_type,
|
||||
:rule_id,
|
||||
:rule_name,
|
||||
:order_id,
|
||||
:execution_status,
|
||||
:result_message,
|
||||
:context_json,
|
||||
:executed_at,
|
||||
NOW()
|
||||
)'
|
||||
);
|
||||
|
||||
$statement->execute([
|
||||
'event_type' => (string) ($data['event_type'] ?? ''),
|
||||
'rule_id' => isset($data['rule_id']) ? (int) $data['rule_id'] : null,
|
||||
'rule_name' => (string) ($data['rule_name'] ?? ''),
|
||||
'order_id' => (int) ($data['order_id'] ?? 0),
|
||||
'execution_status' => (string) ($data['execution_status'] ?? ''),
|
||||
'result_message' => $this->trimNullable((string) ($data['result_message'] ?? '')),
|
||||
'context_json' => $this->encodeJson($data['context'] ?? null),
|
||||
'executed_at' => (string) ($data['executed_at'] ?? date('Y-m-d H:i:s')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$safePage = max(1, $page);
|
||||
$safePerPage = max(1, min(100, $perPage));
|
||||
$offset = ($safePage - 1) * $safePerPage;
|
||||
|
||||
['where' => $whereSql, 'params' => $params] = $this->buildFilters($filters);
|
||||
|
||||
$sql = 'SELECT id, event_type, rule_id, rule_name, order_id, execution_status, result_message, context_json, executed_at
|
||||
FROM automation_execution_logs
|
||||
' . $whereSql . '
|
||||
ORDER BY executed_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset';
|
||||
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$statement->bindValue(':' . $key, $value);
|
||||
}
|
||||
$statement->bindValue(':limit', $safePerPage, PDO::PARAM_INT);
|
||||
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$decoded = json_decode((string) ($row['context_json'] ?? ''), true);
|
||||
$row['context_json'] = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function count(array $filters): int
|
||||
{
|
||||
['where' => $whereSql, 'params' => $params] = $this->buildFilters($filters);
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM automation_execution_logs ' . $whereSql
|
||||
);
|
||||
$statement->execute($params);
|
||||
$value = $statement->fetchColumn();
|
||||
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
public function purgeOlderThanDays(int $days): int
|
||||
{
|
||||
$safeDays = max(1, min(3650, $days));
|
||||
$statement = $this->pdo->prepare(
|
||||
'DELETE FROM automation_execution_logs WHERE executed_at < DATE_SUB(NOW(), INTERVAL :days DAY)'
|
||||
);
|
||||
$statement->bindValue(':days', $safeDays, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
|
||||
return $statement->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listEventTypes(): array
|
||||
{
|
||||
$statement = $this->pdo->query(
|
||||
'SELECT DISTINCT event_type FROM automation_execution_logs ORDER BY event_type ASC'
|
||||
);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('strval', $rows), static fn (string $value): bool => $value !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{where:string,params:array<string,mixed>}
|
||||
*/
|
||||
private function buildFilters(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$eventType = trim((string) ($filters['event_type'] ?? ''));
|
||||
if ($eventType !== '') {
|
||||
$where[] = 'event_type = :event_type';
|
||||
$params['event_type'] = $eventType;
|
||||
}
|
||||
|
||||
$executionStatus = trim((string) ($filters['execution_status'] ?? ''));
|
||||
if ($executionStatus !== '') {
|
||||
$where[] = 'execution_status = :execution_status';
|
||||
$params['execution_status'] = $executionStatus;
|
||||
}
|
||||
|
||||
$ruleId = (int) ($filters['rule_id'] ?? 0);
|
||||
if ($ruleId > 0) {
|
||||
$where[] = 'rule_id = :rule_id';
|
||||
$params['rule_id'] = $ruleId;
|
||||
}
|
||||
|
||||
$orderId = (int) ($filters['order_id'] ?? 0);
|
||||
if ($orderId > 0) {
|
||||
$where[] = 'order_id = :order_id';
|
||||
$params['order_id'] = $orderId;
|
||||
}
|
||||
|
||||
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
|
||||
if ($dateFrom !== '') {
|
||||
$where[] = 'DATE(executed_at) >= :date_from';
|
||||
$params['date_from'] = $dateFrom;
|
||||
}
|
||||
|
||||
$dateTo = trim((string) ($filters['date_to'] ?? ''));
|
||||
if ($dateTo !== '') {
|
||||
$where[] = 'DATE(executed_at) <= :date_to';
|
||||
$params['date_to'] = $dateTo;
|
||||
}
|
||||
|
||||
if ($where === []) {
|
||||
return ['where' => '', 'params' => []];
|
||||
}
|
||||
|
||||
return ['where' => 'WHERE ' . implode(' AND ', $where), 'params' => $params];
|
||||
}
|
||||
|
||||
private function trimNullable(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_substr($trimmed, 0, 500);
|
||||
}
|
||||
|
||||
private function encodeJson(mixed $data): ?string
|
||||
{
|
||||
if (!is_array($data) || $data === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ final class AutomationRepository
|
||||
(SELECT COUNT(*) FROM automation_conditions WHERE rule_id = r.id) AS conditions_count,
|
||||
(SELECT COUNT(*) FROM automation_actions WHERE rule_id = r.id) AS actions_count
|
||||
FROM automation_rules r
|
||||
ORDER BY r.created_at DESC
|
||||
ORDER BY r.name ASC, r.id DESC
|
||||
';
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->execute();
|
||||
@@ -209,6 +209,34 @@ final class AutomationRepository
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int,name:string}>
|
||||
*/
|
||||
public function listRuleOptions(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, name FROM automation_rules ORDER BY name ASC'
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{code:string,name:string}>
|
||||
*/
|
||||
public function listActiveOrderStatuses(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT code, name FROM order_statuses WHERE is_active = 1 ORDER BY name ASC, id ASC'
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ final class AutomationService
|
||||
|
||||
public function __construct(
|
||||
private readonly AutomationRepository $repository,
|
||||
private readonly AutomationExecutionLogRepository $executionLogs,
|
||||
private readonly EmailSendingService $emailService,
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
@@ -74,11 +75,22 @@ final class AutomationService
|
||||
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
|
||||
$ruleName = (string) ($rule['name'] ?? '');
|
||||
$ruleContext = $this->withExecution($context, $executionKey);
|
||||
$ruleMatched = $this->evaluateConditions($conditions, $order, $ruleContext);
|
||||
|
||||
if ($this->evaluateConditions($conditions, $order, $ruleContext)) {
|
||||
if ($ruleMatched) {
|
||||
$this->executeActions($actions, $orderId, $ruleName, $ruleContext);
|
||||
$this->logExecution($eventType, $ruleId, $ruleName, $orderId, 'success', 'Wykonano akcje automatyzacji', $ruleContext);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
} catch (Throwable $exception) {
|
||||
$this->logExecution(
|
||||
$eventType,
|
||||
(int) ($rule['id'] ?? 0),
|
||||
(string) ($rule['name'] ?? ''),
|
||||
$orderId,
|
||||
'failed',
|
||||
$exception->getMessage(),
|
||||
$context
|
||||
);
|
||||
// Blad jednej reguly nie blokuje kolejnych
|
||||
}
|
||||
}
|
||||
@@ -195,6 +207,11 @@ final class AutomationService
|
||||
|
||||
if ($type === 'update_shipment_status') {
|
||||
$this->handleUpdateShipmentStatus($config, $orderId, $ruleName, $context);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'update_order_status') {
|
||||
$this->handleUpdateOrderStatus($config, $orderId, $ruleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,6 +444,32 @@ final class AutomationService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
private function handleUpdateOrderStatus(array $config, int $orderId, string $ruleName): void
|
||||
{
|
||||
$statusCode = trim((string) ($config['status_code'] ?? ''));
|
||||
if ($statusCode === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$actorName = 'Automatyzacja: ' . $ruleName;
|
||||
$updated = $this->orders->updateOrderStatus($orderId, $statusCode, 'system', $actorName);
|
||||
if ($updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'automation_order_status_failed',
|
||||
$actorName . ' - nie udalo sie zmienic statusu zamowienia',
|
||||
['target_status_code' => $statusCode],
|
||||
'system',
|
||||
$actorName
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveStatusFromActionKey(string $statusKey): ?string
|
||||
{
|
||||
if ($statusKey === '' || !isset(self::SHIPMENT_STATUS_OPTION_MAP[$statusKey])) {
|
||||
@@ -741,4 +784,84 @@ final class AutomationService
|
||||
return uniqid('chain_', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function logExecution(
|
||||
string $eventType,
|
||||
int $ruleId,
|
||||
string $ruleName,
|
||||
int $orderId,
|
||||
string $status,
|
||||
string $message,
|
||||
array $context
|
||||
): void {
|
||||
if ($ruleId <= 0 || $orderId <= 0 || $ruleName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->executionLogs->create([
|
||||
'event_type' => $eventType,
|
||||
'rule_id' => $ruleId,
|
||||
'rule_name' => $ruleName,
|
||||
'order_id' => $orderId,
|
||||
'execution_status' => $status,
|
||||
'result_message' => mb_substr(trim($message), 0, 500),
|
||||
'context' => $this->sanitizeContext($context),
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
// Historia automatyzacji nie moze blokowac glownego flow.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeContext(array $context): array
|
||||
{
|
||||
$sanitized = [];
|
||||
foreach ($context as $key => $value) {
|
||||
if (is_scalar($value) || $value === null) {
|
||||
$sanitized[(string) $key] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sanitized[(string) $key] = $this->sanitizeArray($value, 2);
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $value
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sanitizeArray(array $value, int $depth): array
|
||||
{
|
||||
if ($depth <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sanitized = [];
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_scalar($item) || $item === null) {
|
||||
$sanitized[$key] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($item)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($item, $depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/Modules/Cron/AutomationHistoryCleanupHandler.php
Normal file
29
src/Modules/Cron/AutomationHistoryCleanupHandler.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Modules\Automation\AutomationExecutionLogRepository;
|
||||
|
||||
final class AutomationHistoryCleanupHandler
|
||||
{
|
||||
public function __construct(private readonly AutomationExecutionLogRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(array $payload): array
|
||||
{
|
||||
$days = max(1, (int) ($payload['days'] ?? 30));
|
||||
$deletedCount = $this->repository->purgeOlderThanDays($days);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'days' => $days,
|
||||
'deleted_count' => $deletedCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Core\View\Template;
|
||||
use App\Modules\Accounting\ReceiptRepository;
|
||||
use App\Modules\Automation\AutomationRepository;
|
||||
use App\Modules\Automation\AutomationService;
|
||||
use App\Modules\Automation\AutomationExecutionLogRepository;
|
||||
use App\Modules\Email\AttachmentGenerator;
|
||||
use App\Modules\Email\EmailSendingService;
|
||||
use App\Modules\Email\VariableResolver;
|
||||
@@ -163,6 +164,9 @@ final class CronHandlerFactory
|
||||
new ShipmentPackageRepository($this->db),
|
||||
$automationService
|
||||
),
|
||||
'automation_history_cleanup' => new AutomationHistoryCleanupHandler(
|
||||
new AutomationExecutionLogRepository($this->db)
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -170,6 +174,7 @@ final class CronHandlerFactory
|
||||
private function buildAutomationService(OrdersRepository $ordersRepository): AutomationService
|
||||
{
|
||||
$automationRepository = new AutomationRepository($this->db);
|
||||
$executionLogRepository = new AutomationExecutionLogRepository($this->db);
|
||||
$companySettingsRepository = new CompanySettingsRepository($this->db);
|
||||
$emailTemplateRepository = new EmailTemplateRepository($this->db);
|
||||
$emailMailboxRepository = new EmailMailboxRepository(
|
||||
@@ -186,7 +191,7 @@ final class CronHandlerFactory
|
||||
$ordersRepository,
|
||||
$emailTemplateRepository,
|
||||
$emailMailboxRepository,
|
||||
new VariableResolver(),
|
||||
new VariableResolver(new ShipmentPackageRepository($this->db)),
|
||||
new AttachmentGenerator(
|
||||
new ReceiptRepository($this->db),
|
||||
new ReceiptConfigRepository($this->db),
|
||||
@@ -196,6 +201,7 @@ final class CronHandlerFactory
|
||||
|
||||
return new AutomationService(
|
||||
$automationRepository,
|
||||
$executionLogRepository,
|
||||
$emailService,
|
||||
$ordersRepository,
|
||||
$companySettingsRepository,
|
||||
|
||||
@@ -4,8 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Email;
|
||||
|
||||
use App\Modules\Shipments\DeliveryStatus;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
|
||||
final class VariableResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShipmentPackageRepository $shipmentPackageRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
@@ -27,7 +35,7 @@ final class VariableResolver
|
||||
$orderedAt = date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
return [
|
||||
$baseVariables = [
|
||||
'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''),
|
||||
'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''),
|
||||
'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')),
|
||||
@@ -45,6 +53,8 @@ final class VariableResolver
|
||||
'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''),
|
||||
'firma.nip' => (string) ($companySettings['tax_number'] ?? ''),
|
||||
];
|
||||
|
||||
return $baseVariables + $this->resolveShipmentVariables($order);
|
||||
}
|
||||
|
||||
public function resolve(string $template, array $variableMap): string
|
||||
@@ -70,4 +80,37 @@ final class VariableResolver
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveShipmentVariables(array $order): array
|
||||
{
|
||||
$orderId = (int) ($order['id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
return [
|
||||
'przesylka.numer' => '',
|
||||
'przesylka.link_sledzenia' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$latestPackage = $this->shipmentPackageRepository->findLatestByOrderId($orderId);
|
||||
if (!is_array($latestPackage)) {
|
||||
return [
|
||||
'przesylka.numer' => '',
|
||||
'przesylka.link_sledzenia' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$trackingNumber = trim((string) ($latestPackage['tracking_number'] ?? ''));
|
||||
$provider = trim((string) ($latestPackage['provider'] ?? ''));
|
||||
$carrierId = trim((string) ($latestPackage['carrier_id'] ?? ''));
|
||||
$trackingUrl = DeliveryStatus::trackingUrl($provider, $trackingNumber, $carrierId) ?? '';
|
||||
|
||||
return [
|
||||
'przesylka.numer' => $trackingNumber,
|
||||
'przesylka.link_sledzenia' => $trackingUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,31 @@ final class AllegroApiClient
|
||||
return $this->postJson($url, $accessToken, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateCheckoutFormFulfillment(
|
||||
string $environment,
|
||||
string $accessToken,
|
||||
string $checkoutFormId,
|
||||
string $status
|
||||
): array {
|
||||
$safeId = rawurlencode(trim($checkoutFormId));
|
||||
if ($safeId === '') {
|
||||
throw new AllegroApiException('Brak ID zamowienia Allegro do aktualizacji statusu.');
|
||||
}
|
||||
|
||||
$normalizedStatus = strtoupper(trim($status));
|
||||
if ($normalizedStatus === '') {
|
||||
throw new AllegroApiException('Brak statusu Allegro do aktualizacji.');
|
||||
}
|
||||
|
||||
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId . '/fulfillment';
|
||||
return $this->putJson($url, $accessToken, [
|
||||
'status' => $normalizedStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCaBundlePath(): ?string
|
||||
{
|
||||
$envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? '');
|
||||
@@ -256,6 +281,71 @@ final class AllegroApiClient
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function putJson(string $url, string $accessToken, array $body): array
|
||||
{
|
||||
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new AllegroApiException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $this->withSslOptions([
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $jsonBody,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/vnd.allegro.public.v1+json',
|
||||
'Content-Type: application/vnd.allegro.public.v1+json',
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
],
|
||||
]));
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
$ch = null;
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new AllegroApiException('Blad polaczenia z API Allegro: ' . $curlError);
|
||||
}
|
||||
|
||||
$json = json_decode((string) $responseBody, true);
|
||||
if (!is_array($json)) {
|
||||
throw new AllegroApiException('Nieprawidlowy JSON odpowiedzi API Allegro.');
|
||||
}
|
||||
|
||||
if ($httpCode === 401) {
|
||||
throw new AllegroApiException('ALLEGRO_HTTP_401');
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$message = trim((string) ($json['message'] ?? ''));
|
||||
$errors = is_array($json['errors'] ?? null) ? $json['errors'] : [];
|
||||
if ($message === '' && $errors !== []) {
|
||||
$parts = [];
|
||||
foreach ($errors as $err) {
|
||||
if (is_array($err)) {
|
||||
$parts[] = trim((string) ($err['message'] ?? ($err['userMessage'] ?? '')));
|
||||
}
|
||||
}
|
||||
$message = implode('; ', array_filter($parts));
|
||||
}
|
||||
if ($message === '') {
|
||||
$message = 'Blad API Allegro.';
|
||||
}
|
||||
throw new AllegroApiException('API Allegro HTTP ' . $httpCode . ': ' . $message);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
|
||||
@@ -113,6 +113,45 @@ final class AllegroOrderSyncStateRepository
|
||||
$this->upsertState($integrationId, $changes, true);
|
||||
}
|
||||
|
||||
public function getLastStatusPushedAt(int $integrationId): ?string
|
||||
{
|
||||
if ($integrationId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$columns = $this->resolveColumns();
|
||||
if (!$columns['has_table'] || !$columns['has_last_status_pushed_at']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT last_status_pushed_at
|
||||
FROM integration_order_sync_state
|
||||
WHERE integration_id = :integration_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['integration_id' => $integrationId]);
|
||||
$value = $statement->fetchColumn();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
public function updateLastStatusPushedAt(int $integrationId, string $datetime): void
|
||||
{
|
||||
$this->upsertState($integrationId, [
|
||||
'last_status_pushed_at' => trim($datetime),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $changes
|
||||
*/
|
||||
@@ -148,6 +187,9 @@ final class AllegroOrderSyncStateRepository
|
||||
'last_synced_updated_at' => $updatedAtColumn,
|
||||
'last_synced_source_order_id' => $sourceOrderIdColumn,
|
||||
];
|
||||
if ($columns['has_last_status_pushed_at']) {
|
||||
$columnMap['last_status_pushed_at'] = 'last_status_pushed_at';
|
||||
}
|
||||
|
||||
foreach ($columnMap as $inputKey => $columnName) {
|
||||
if (!array_key_exists($inputKey, $changes)) {
|
||||
@@ -185,7 +227,8 @@ final class AllegroOrderSyncStateRepository
|
||||
* has_table:bool,
|
||||
* updated_at_column:?string,
|
||||
* source_order_id_column:?string,
|
||||
* has_last_success_at:bool
|
||||
* has_last_success_at:bool,
|
||||
* has_last_status_pushed_at:bool
|
||||
* }
|
||||
*/
|
||||
private function resolveColumns(): array
|
||||
@@ -199,6 +242,7 @@ final class AllegroOrderSyncStateRepository
|
||||
'updated_at_column' => null,
|
||||
'source_order_id_column' => null,
|
||||
'has_last_success_at' => false,
|
||||
'has_last_status_pushed_at' => false,
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -243,6 +287,7 @@ final class AllegroOrderSyncStateRepository
|
||||
}
|
||||
|
||||
$result['has_last_success_at'] = isset($available['last_success_at']);
|
||||
$result['has_last_status_pushed_at'] = isset($available['last_status_pushed_at']);
|
||||
$this->columns = $result;
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -124,4 +124,37 @@ final class AllegroStatusMappingRepository
|
||||
return $mapped !== '' ? $mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> orderpro_status_code => allegro_status_code
|
||||
*/
|
||||
public function buildOrderproToAllegroMap(): array
|
||||
{
|
||||
$statement = $this->pdo->query(
|
||||
'SELECT allegro_status_code, orderpro_status_code
|
||||
FROM allegro_order_status_mappings
|
||||
WHERE orderpro_status_code IS NOT NULL
|
||||
AND orderpro_status_code <> ""
|
||||
ORDER BY id ASC'
|
||||
);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$orderproCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
|
||||
$allegroCode = strtolower(trim((string) ($row['allegro_status_code'] ?? '')));
|
||||
if ($orderproCode === '' || $allegroCode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($map[$orderproCode])) {
|
||||
$map[$orderproCode] = $allegroCode;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ final class AllegroStatusSyncService
|
||||
public function __construct(
|
||||
private readonly CronRepository $cronRepository,
|
||||
private readonly AllegroOrderImportService $orderImportService,
|
||||
private readonly AllegroApiClient $apiClient,
|
||||
private readonly AllegroTokenManager $tokenManager,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings,
|
||||
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
|
||||
private readonly AllegroIntegrationRepository $integrationRepository,
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
@@ -37,19 +42,22 @@ final class AllegroStatusSyncService
|
||||
}
|
||||
|
||||
if ($direction === self::DIRECTION_ORDERPRO_TO_ALLEGRO) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'direction' => $direction,
|
||||
'processed' => 0,
|
||||
'message' => 'Kierunek orderPRO -> Allegro nie jest jeszcze wdrozony.',
|
||||
];
|
||||
return $this->syncPushDirection();
|
||||
}
|
||||
|
||||
return $this->syncPullDirection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncPullDirection(): array
|
||||
{
|
||||
$orders = $this->findOrdersNeedingStatusSync();
|
||||
|
||||
$result = [
|
||||
'ok' => true,
|
||||
'direction' => $direction,
|
||||
'direction' => self::DIRECTION_ALLEGRO_TO_ORDERPRO,
|
||||
'processed' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => [],
|
||||
@@ -57,6 +65,9 @@ final class AllegroStatusSyncService
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$sourceOrderId = (string) ($order['source_order_id'] ?? '');
|
||||
if ($sourceOrderId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->orderImportService->importSingleOrder($sourceOrderId, 'status_sync');
|
||||
@@ -78,6 +89,149 @@ final class AllegroStatusSyncService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncPushDirection(): array
|
||||
{
|
||||
$integrationId = $this->integrationRepository->getActiveIntegrationId();
|
||||
if ($integrationId <= 0) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
'pushed' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'message' => 'Brak aktywnej integracji Allegro.',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$reverseMap = $this->statusMappings->buildOrderproToAllegroMap();
|
||||
if ($reverseMap === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
'pushed' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'message' => 'Brak mapowan statusow orderPRO -> Allegro.',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
[$accessToken, $environment] = $this->tokenManager->resolveToken();
|
||||
$lastStatusPushedAt = $this->syncStateRepository->getLastStatusPushedAt($integrationId);
|
||||
$orders = $this->findOrdersForPush($integrationId, $lastStatusPushedAt);
|
||||
|
||||
if ($orders === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
'pushed' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'message' => 'Brak zamowien do synchronizacji statusow.',
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$result = [
|
||||
'ok' => true,
|
||||
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
'pushed' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
$latestPushedChangeAt = null;
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
|
||||
$orderproStatusCode = strtolower(trim((string) ($order['orderpro_status_code'] ?? '')));
|
||||
if ($sourceOrderId === '' || $orderproStatusCode === '') {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$allegroStatusCode = $reverseMap[$orderproStatusCode] ?? null;
|
||||
if ($allegroStatusCode === null || trim($allegroStatusCode) === '') {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$resolved = $this->pushStatusWith401Retry(
|
||||
$environment,
|
||||
$accessToken,
|
||||
$sourceOrderId,
|
||||
$allegroStatusCode
|
||||
);
|
||||
$environment = $resolved['environment'];
|
||||
$accessToken = $resolved['token'];
|
||||
$result['pushed']++;
|
||||
|
||||
$changeAt = trim((string) ($order['latest_change'] ?? ''));
|
||||
if ($changeAt !== '' && ($latestPushedChangeAt === null || $changeAt > $latestPushedChangeAt)) {
|
||||
$latestPushedChangeAt = $changeAt;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$result['failed']++;
|
||||
$errors = is_array($result['errors']) ? $result['errors'] : [];
|
||||
if (count($errors) < 20) {
|
||||
$errors[] = [
|
||||
'source_order_id' => $sourceOrderId,
|
||||
'orderpro_status_code' => $orderproStatusCode,
|
||||
'allegro_status_code' => $allegroStatusCode,
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
$result['errors'] = $errors;
|
||||
}
|
||||
}
|
||||
|
||||
if ($latestPushedChangeAt !== null) {
|
||||
$this->syncStateRepository->updateLastStatusPushedAt($integrationId, $latestPushedChangeAt);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function pushStatusWith401Retry(
|
||||
string $environment,
|
||||
string $accessToken,
|
||||
string $checkoutFormId,
|
||||
string $allegroStatusCode
|
||||
): array {
|
||||
try {
|
||||
$this->apiClient->updateCheckoutFormFulfillment(
|
||||
$environment,
|
||||
$accessToken,
|
||||
$checkoutFormId,
|
||||
$allegroStatusCode
|
||||
);
|
||||
|
||||
return ['environment' => $environment, 'token' => $accessToken];
|
||||
} catch (Throwable $exception) {
|
||||
if (!str_contains($exception->getMessage(), 'ALLEGRO_HTTP_401')) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
[$refreshedToken, $refreshedEnvironment] = $this->tokenManager->resolveToken();
|
||||
$this->apiClient->updateCheckoutFormFulfillment(
|
||||
$refreshedEnvironment,
|
||||
$refreshedToken,
|
||||
$checkoutFormId,
|
||||
$allegroStatusCode
|
||||
);
|
||||
|
||||
return ['environment' => $refreshedEnvironment, 'token' => $refreshedToken];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -104,13 +258,53 @@ final class AllegroStatusSyncService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function findOrdersForPush(int $integrationId, ?string $lastStatusPushedAt): array
|
||||
{
|
||||
$sinceDate = $lastStatusPushedAt;
|
||||
if ($sinceDate === null || trim($sinceDate) === '') {
|
||||
$sinceDate = date('Y-m-d H:i:s', strtotime('-24 hours'));
|
||||
}
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT
|
||||
o.id,
|
||||
o.source_order_id,
|
||||
o.external_status_id AS orderpro_status_code,
|
||||
MAX(h.changed_at) AS latest_change
|
||||
FROM order_status_history h
|
||||
INNER JOIN orders o ON o.id = h.order_id
|
||||
WHERE o.source = :source
|
||||
AND o.integration_id = :integration_id
|
||||
AND h.change_source = :change_source
|
||||
AND h.changed_at > :since_date
|
||||
GROUP BY o.id, o.source_order_id, o.external_status_id
|
||||
ORDER BY latest_change ASC
|
||||
LIMIT ' . self::MAX_ORDERS_PER_RUN
|
||||
);
|
||||
$statement->execute([
|
||||
'source' => IntegrationSources::ALLEGRO,
|
||||
'integration_id' => $integrationId,
|
||||
'change_source' => 'manual',
|
||||
'since_date' => $sinceDate,
|
||||
]);
|
||||
|
||||
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function markOrderStatusChecked(int $orderId): void
|
||||
{
|
||||
try {
|
||||
$statement = $this->pdo->prepare('UPDATE orders SET last_status_checked_at = NOW() WHERE id = ?');
|
||||
$statement->execute([$orderId]);
|
||||
} catch (Throwable) {
|
||||
// Błąd zapisu logu nie powinien przerywać pętli synchronizacji
|
||||
// Blad zapisu znacznika nie powinien przerywac petli synchronizacji.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,13 @@ final class EmailTemplateController
|
||||
'nip' => 'NIP',
|
||||
],
|
||||
],
|
||||
'przesylka' => [
|
||||
'label' => 'Przesylka',
|
||||
'vars' => [
|
||||
'numer' => 'Numer przesylki (tracking)',
|
||||
'link_sledzenia' => 'Link sledzenia zalezny od kuriera',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
private const ATTACHMENT_TYPES = [
|
||||
@@ -75,6 +82,8 @@ final class EmailTemplateController
|
||||
'adres.kraj' => 'PL',
|
||||
'firma.nazwa' => 'Przykladowa Firma Sp. z o.o.',
|
||||
'firma.nip' => '5271234567',
|
||||
'przesylka.numer' => '123456789012345678901234',
|
||||
'przesylka.link_sledzenia' => 'https://inpost.pl/sledzenie-przesylek?number=123456789012345678901234',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -13,6 +13,8 @@ use Throwable;
|
||||
|
||||
final class ApaczkaShipmentService implements ShipmentProviderInterface
|
||||
{
|
||||
private const PICKUP_DATE_RETRY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* @var array<string, array{street:string,postal_code:string,city:string}>
|
||||
*/
|
||||
@@ -146,7 +148,7 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->sendOrder($appId, $appSecret, $apiPayload);
|
||||
$response = $this->sendOrderWithPickupFallback($appId, $appSecret, $apiPayload);
|
||||
} catch (Throwable $exception) {
|
||||
$errorMessage = $this->buildShipmentErrorMessage(
|
||||
$exception,
|
||||
@@ -179,6 +181,67 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $apiPayload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sendOrderWithPickupFallback(string $appId, string $appSecret, array &$apiPayload): array
|
||||
{
|
||||
$attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return $this->apiClient->sendOrder($appId, $appSecret, $apiPayload);
|
||||
} catch (Throwable $exception) {
|
||||
if (
|
||||
!$this->isPickupDateUnavailableError($exception)
|
||||
|| !$this->shiftPickupDateToNextBusinessDay($apiPayload)
|
||||
|| $attempt >= self::PICKUP_DATE_RETRY_DAYS
|
||||
) {
|
||||
throw $exception;
|
||||
}
|
||||
$attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isPickupDateUnavailableError(Throwable $exception): bool
|
||||
{
|
||||
$message = strtolower(trim($exception->getMessage()));
|
||||
if ($message === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains($message, 'pickup not available for selected day')
|
||||
|| str_contains($message, "can\\u2019t place an order today")
|
||||
|| str_contains($message, "can't place an order today")
|
||||
|| str_contains($message, 'change its date to another working day');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $apiPayload
|
||||
*/
|
||||
private function shiftPickupDateToNextBusinessDay(array &$apiPayload): bool
|
||||
{
|
||||
$pickup = is_array($apiPayload['pickup'] ?? null) ? $apiPayload['pickup'] : null;
|
||||
if ($pickup === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pickupType = strtoupper(trim((string) ($pickup['type'] ?? '')));
|
||||
$pickupDate = trim((string) ($pickup['date'] ?? ''));
|
||||
if ($pickupType !== 'COURIER' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $pickupDate) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextDateTimestamp = strtotime('+1 day', strtotime($pickupDate));
|
||||
if ($nextDateTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiPayload['pickup']['date'] = $this->normalizeCourierPickupDate(date('Y-m-d', $nextDateTimestamp));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user