update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user