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

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