This commit is contained in:
2026-03-28 15:04:35 +01:00
parent c1d0d7762f
commit 2ab0d0e90e
44 changed files with 3027 additions and 493 deletions

View File

@@ -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;
}

View File

@@ -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'] !== '';
}
}

View 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;
}
}

View File

@@ -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>>
*/

View File

@@ -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;
}
}

View 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,
];
}
}

View File

@@ -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,

View File

@@ -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,
];
}
}

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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.
}
}
}

View File

@@ -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(

View File

@@ -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>
*/