feat(16-automated-tasks): moduł zadań automatycznych — CRUD + watcher/executor
Reguły automatyzacji oparte na zdarzeniach (receipt.created) z warunkami (integracja/kanał sprzedaży, AND logic) i akcjami (wyślij e-mail z 3 trybami odbiorcy: klient / firma / klient+firma). Trigger w ReceiptController po utworzeniu paragonu — błąd automatyzacji nie blokuje sukcesu paragonu. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Automation\AutomationService;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\ReceiptConfigRepository;
|
||||
@@ -24,7 +25,8 @@ final class ReceiptController
|
||||
private readonly ReceiptRepository $receipts,
|
||||
private readonly ReceiptConfigRepository $receiptConfigs,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $orders
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly AutomationService $automation
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -189,6 +191,12 @@ final class ReceiptController
|
||||
);
|
||||
|
||||
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
|
||||
|
||||
try {
|
||||
$this->automation->trigger('receipt.created', $orderId);
|
||||
} catch (Throwable) {
|
||||
// Blad automatyzacji nie blokuje sukcesu paragonu
|
||||
}
|
||||
} catch (Throwable) {
|
||||
Flash::set('order.error', 'Blad wystawiania paragonu');
|
||||
}
|
||||
|
||||
346
src/Modules/Automation/AutomationController.php
Normal file
346
src/Modules/Automation/AutomationController.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Automation;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class AutomationController
|
||||
{
|
||||
private const ALLOWED_EVENTS = ['receipt.created'];
|
||||
private const ALLOWED_CONDITION_TYPES = ['integration'];
|
||||
private const ALLOWED_ACTION_TYPES = ['send_email'];
|
||||
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
|
||||
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly AutomationRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$rules = $this->repository->findAll();
|
||||
|
||||
$html = $this->template->render('automation/index', [
|
||||
'title' => 'Zadania automatyczne',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'automation',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'rules' => $rules,
|
||||
'successMessage' => Flash::get('settings.automation.success', ''),
|
||||
'errorMessage' => Flash::get('settings.automation.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return $this->renderForm(null);
|
||||
}
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$id = (int) $request->input('id', '0');
|
||||
$rule = $id > 0 ? $this->repository->findById($id) : null;
|
||||
|
||||
if ($rule === null) {
|
||||
Flash::set('settings.automation.error', 'Nie znaleziono reguly');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
return $this->renderForm($rule);
|
||||
}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$validationError = $this->validateInput($request);
|
||||
if ($validationError !== null) {
|
||||
Flash::set('settings.automation.error', $validationError);
|
||||
return Response::redirect('/settings/automation/create');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->create(
|
||||
$this->extractRuleData($request),
|
||||
$this->extractConditions($request),
|
||||
$this->extractActions($request)
|
||||
);
|
||||
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad zapisu zadania automatycznego');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
$validationError = $this->validateInput($request);
|
||||
if ($validationError !== null) {
|
||||
Flash::set('settings.automation.error', $validationError);
|
||||
return Response::redirect('/settings/automation/edit?id=' . $id);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->update(
|
||||
$id,
|
||||
$this->extractRuleData($request),
|
||||
$this->extractConditions($request),
|
||||
$this->extractActions($request)
|
||||
);
|
||||
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad aktualizacji zadania automatycznego');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
public function destroy(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->delete($id);
|
||||
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo usuniete');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad usuwania zadania automatycznego');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
public function toggleStatus(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->toggleActive($id);
|
||||
Flash::set('settings.automation.success', 'Status zadania zostal zmieniony');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad zmiany statusu');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
private function renderForm(?array $rule): Response
|
||||
{
|
||||
$html = $this->template->render('automation/form', [
|
||||
'title' => $rule !== null ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'automation',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'rule' => $rule,
|
||||
'integrations' => $this->repository->listOrderIntegrations(),
|
||||
'emailTemplates' => $this->repository->listEmailTemplates(),
|
||||
'eventTypes' => self::ALLOWED_EVENTS,
|
||||
'conditionTypes' => self::ALLOWED_CONDITION_TYPES,
|
||||
'actionTypes' => self::ALLOWED_ACTION_TYPES,
|
||||
'recipientOptions' => self::ALLOWED_RECIPIENTS,
|
||||
'errorMessage' => Flash::get('settings.automation.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
private function validateCsrf(Request $request): ?Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy token CSRF');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateInput(Request $request): ?string
|
||||
{
|
||||
$name = trim((string) $request->input('name', ''));
|
||||
if ($name === '' || mb_strlen($name) > 128) {
|
||||
return 'Nazwa jest wymagana (maks. 128 znakow)';
|
||||
}
|
||||
|
||||
$eventType = (string) $request->input('event_type', '');
|
||||
if (!in_array($eventType, self::ALLOWED_EVENTS, true)) {
|
||||
return 'Nieprawidlowy typ zdarzenia';
|
||||
}
|
||||
|
||||
$conditions = $this->extractConditions($request);
|
||||
if (count($conditions) === 0) {
|
||||
return 'Wymagany jest co najmniej jeden warunek';
|
||||
}
|
||||
|
||||
$actions = $this->extractActions($request);
|
||||
if (count($actions) === 0) {
|
||||
return 'Wymagana jest co najmniej jedna akcja';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractRuleData(Request $request): array
|
||||
{
|
||||
return [
|
||||
'name' => trim((string) $request->input('name', '')),
|
||||
'event_type' => (string) $request->input('event_type', ''),
|
||||
'is_active' => $request->input('is_active', null) !== null ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{type: string, value: array<string, mixed>}>
|
||||
*/
|
||||
private function extractConditions(Request $request): array
|
||||
{
|
||||
$raw = $request->input('conditions', []);
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($raw as $condition) {
|
||||
if (!is_array($condition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($condition['type'] ?? '');
|
||||
if (!in_array($type, self::ALLOWED_CONDITION_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->parseConditionValue($type, $condition);
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = ['type' => $type, 'value' => $value];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $condition
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function parseConditionValue(string $type, array $condition): ?array
|
||||
{
|
||||
if ($type === 'integration') {
|
||||
$ids = $condition['integration_ids'] ?? [];
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$integrationIds = array_values(array_filter(
|
||||
array_map('intval', $ids),
|
||||
static fn (int $id): bool => $id > 0
|
||||
));
|
||||
|
||||
return count($integrationIds) > 0 ? ['integration_ids' => $integrationIds] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{type: string, config: array<string, mixed>}>
|
||||
*/
|
||||
private function extractActions(Request $request): array
|
||||
{
|
||||
$raw = $request->input('actions', []);
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($raw as $action) {
|
||||
if (!is_array($action)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($action['type'] ?? '');
|
||||
if (!in_array($type, self::ALLOWED_ACTION_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = $this->parseActionConfig($type, $action);
|
||||
if ($config === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = ['type' => $type, 'config' => $config];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $action
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function parseActionConfig(string $type, array $action): ?array
|
||||
{
|
||||
if ($type === 'send_email') {
|
||||
$templateId = (int) ($action['template_id'] ?? 0);
|
||||
$recipient = (string) ($action['recipient'] ?? '');
|
||||
|
||||
if ($templateId <= 0 || !in_array($recipient, self::ALLOWED_RECIPIENTS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['template_id' => $templateId, 'recipient' => $recipient];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
277
src/Modules/Automation/AutomationRepository.php
Normal file
277
src/Modules/Automation/AutomationRepository.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Automation;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class AutomationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
$sql = '
|
||||
SELECT r.id, r.name, r.event_type, r.is_active, r.created_at, r.updated_at,
|
||||
(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
|
||||
';
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare('SELECT * FROM automation_rules WHERE id = :id LIMIT 1');
|
||||
$statement->execute(['id' => $id]);
|
||||
$rule = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!is_array($rule)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rule['conditions'] = $this->loadConditions($id);
|
||||
$rule['actions'] = $this->loadActions($id);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<array<string, mixed>> $conditions
|
||||
* @param list<array<string, mixed>> $actions
|
||||
*/
|
||||
public function create(array $data, array $conditions, array $actions): int
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO automation_rules (name, event_type, is_active)
|
||||
VALUES (:name, :event_type, :is_active)'
|
||||
);
|
||||
$statement->execute([
|
||||
'name' => $data['name'],
|
||||
'event_type' => $data['event_type'],
|
||||
'is_active' => (int) ($data['is_active'] ?? 1),
|
||||
]);
|
||||
|
||||
$ruleId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->insertConditions($ruleId, $conditions);
|
||||
$this->insertActions($ruleId, $actions);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return $ruleId;
|
||||
} catch (Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<array<string, mixed>> $conditions
|
||||
* @param list<array<string, mixed>> $actions
|
||||
*/
|
||||
public function update(int $id, array $data, array $conditions, array $actions): bool
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE automation_rules SET name = :name, event_type = :event_type, is_active = :is_active WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $id,
|
||||
'name' => $data['name'],
|
||||
'event_type' => $data['event_type'],
|
||||
'is_active' => (int) ($data['is_active'] ?? 1),
|
||||
]);
|
||||
|
||||
$this->pdo->prepare('DELETE FROM automation_conditions WHERE rule_id = :id')->execute(['id' => $id]);
|
||||
$this->pdo->prepare('DELETE FROM automation_actions WHERE rule_id = :id')->execute(['id' => $id]);
|
||||
|
||||
$this->insertConditions($id, $conditions);
|
||||
$this->insertActions($id, $actions);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$statement = $this->pdo->prepare('DELETE FROM automation_rules WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
public function toggleActive(int $id): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE automation_rules SET is_active = NOT is_active WHERE id = :id'
|
||||
);
|
||||
$statement->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findActiveByEvent(string $eventType): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, name, event_type FROM automation_rules WHERE event_type = :event_type AND is_active = 1'
|
||||
);
|
||||
$statement->execute(['event_type' => $eventType]);
|
||||
$rules = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!is_array($rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($rules as &$rule) {
|
||||
$ruleId = (int) $rule['id'];
|
||||
$rule['conditions'] = $this->loadConditions($ruleId);
|
||||
$rule['actions'] = $this->loadActions($ruleId);
|
||||
}
|
||||
unset($rule);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, type: string, name: string}>
|
||||
*/
|
||||
public function listOrderIntegrations(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
"SELECT id, type, name FROM integrations WHERE type IN ('allegro', 'shoppro') AND is_active = 1 ORDER BY type, name"
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, name: string}>
|
||||
*/
|
||||
public function listEmailTemplates(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, name FROM email_templates WHERE is_active = 1 ORDER BY name'
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function loadConditions(int $ruleId): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, condition_type, condition_value, sort_order FROM automation_conditions WHERE rule_id = :rule_id ORDER BY sort_order'
|
||||
);
|
||||
$statement->execute(['rule_id' => $ruleId]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$decoded = json_decode((string) ($row['condition_value'] ?? '{}'), true);
|
||||
$row['condition_value'] = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function loadActions(int $ruleId): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, action_type, action_config, sort_order FROM automation_actions WHERE rule_id = :rule_id ORDER BY sort_order'
|
||||
);
|
||||
$statement->execute(['rule_id' => $ruleId]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$decoded = json_decode((string) ($row['action_config'] ?? '{}'), true);
|
||||
$row['action_config'] = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $conditions
|
||||
*/
|
||||
private function insertConditions(int $ruleId, array $conditions): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO automation_conditions (rule_id, condition_type, condition_value, sort_order)
|
||||
VALUES (:rule_id, :condition_type, :condition_value, :sort_order)'
|
||||
);
|
||||
|
||||
foreach ($conditions as $index => $condition) {
|
||||
$statement->execute([
|
||||
'rule_id' => $ruleId,
|
||||
'condition_type' => $condition['type'],
|
||||
'condition_value' => json_encode($condition['value'], JSON_UNESCAPED_UNICODE),
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $actions
|
||||
*/
|
||||
private function insertActions(int $ruleId, array $actions): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO automation_actions (rule_id, action_type, action_config, sort_order)
|
||||
VALUES (:rule_id, :action_type, :action_config, :sort_order)'
|
||||
);
|
||||
|
||||
foreach ($actions as $index => $action) {
|
||||
$statement->execute([
|
||||
'rule_id' => $ruleId,
|
||||
'action_type' => $action['type'],
|
||||
'action_config' => json_encode($action['config'], JSON_UNESCAPED_UNICODE),
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/Modules/Automation/AutomationService.php
Normal file
165
src/Modules/Automation/AutomationService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Automation;
|
||||
|
||||
use App\Modules\Email\EmailSendingService;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use Throwable;
|
||||
|
||||
final class AutomationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AutomationRepository $repository,
|
||||
private readonly EmailSendingService $emailService,
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly CompanySettingsRepository $companySettings
|
||||
) {
|
||||
}
|
||||
|
||||
public function trigger(string $eventType, int $orderId): void
|
||||
{
|
||||
$rules = $this->repository->findActiveByEvent($eventType);
|
||||
if ($rules === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
try {
|
||||
$conditions = is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
|
||||
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
|
||||
$ruleName = (string) ($rule['name'] ?? '');
|
||||
|
||||
if ($this->evaluateConditions($conditions, $order)) {
|
||||
$this->executeActions($actions, $orderId, $ruleName);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Blad jednej reguly nie blokuje kolejnych
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $conditions
|
||||
* @param array<string, mixed> $order
|
||||
*/
|
||||
private function evaluateConditions(array $conditions, array $order): bool
|
||||
{
|
||||
foreach ($conditions as $condition) {
|
||||
$type = (string) ($condition['condition_type'] ?? '');
|
||||
$value = is_array($condition['condition_value'] ?? null) ? $condition['condition_value'] : [];
|
||||
|
||||
if (!$this->evaluateSingleCondition($type, $value, $order)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
* @param array<string, mixed> $order
|
||||
*/
|
||||
private function evaluateSingleCondition(string $type, array $value, array $order): bool
|
||||
{
|
||||
if ($type === 'integration') {
|
||||
return $this->evaluateIntegrationCondition($value, $order);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
* @param array<string, mixed> $order
|
||||
*/
|
||||
private function evaluateIntegrationCondition(array $value, array $order): bool
|
||||
{
|
||||
$allowedIds = is_array($value['integration_ids'] ?? null) ? $value['integration_ids'] : [];
|
||||
if ($allowedIds === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderIntegrationId = (int) ($order['integration_id'] ?? 0);
|
||||
if ($orderIntegrationId === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($orderIntegrationId, array_map('intval', $allowedIds), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $actions
|
||||
*/
|
||||
private function executeActions(array $actions, int $orderId, string $ruleName): void
|
||||
{
|
||||
foreach ($actions as $action) {
|
||||
$type = (string) ($action['action_type'] ?? '');
|
||||
$config = is_array($action['action_config'] ?? null) ? $action['action_config'] : [];
|
||||
|
||||
if ($type === 'send_email') {
|
||||
$this->handleSendEmail($config, $orderId, $ruleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
private function handleSendEmail(array $config, int $orderId, string $ruleName): void
|
||||
{
|
||||
$templateId = (int) ($config['template_id'] ?? 0);
|
||||
if ($templateId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recipient = (string) ($config['recipient'] ?? 'client');
|
||||
$actorName = 'Automatyzacja: ' . $ruleName;
|
||||
|
||||
if ($recipient === 'client' || $recipient === 'client_and_company') {
|
||||
$this->emailService->send($orderId, $templateId, null, $actorName);
|
||||
}
|
||||
|
||||
if ($recipient === 'company' || $recipient === 'client_and_company') {
|
||||
$this->sendToCompany($orderId, $templateId, $actorName);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendToCompany(int $orderId, int $templateId, string $actorName): void
|
||||
{
|
||||
$settings = $this->companySettings->getSettings();
|
||||
$companyEmail = trim((string) ($settings['email'] ?? ''));
|
||||
|
||||
if ($companyEmail === '' || filter_var($companyEmail, FILTER_VALIDATE_EMAIL) === false) {
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'automation_email_failed',
|
||||
$actorName . ' — brak adresu e-mail firmy w ustawieniach',
|
||||
['reason' => 'missing_company_email'],
|
||||
'system',
|
||||
$actorName
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$companyName = trim((string) ($settings['company_name'] ?? ''));
|
||||
|
||||
$this->emailService->send(
|
||||
$orderId,
|
||||
$templateId,
|
||||
null,
|
||||
$actorName,
|
||||
$companyEmail,
|
||||
$companyName
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class EmailSendingService
|
||||
/**
|
||||
* @return array{success: bool, error: ?string, log_id: int}
|
||||
*/
|
||||
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null): array
|
||||
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null, ?string $recipientEmailOverride = null, ?string $recipientNameOverride = null): array
|
||||
{
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
@@ -48,12 +48,16 @@ final class EmailSendingService
|
||||
return ['success' => false, 'error' => 'Brak skonfigurowanej skrzynki SMTP', 'log_id' => 0];
|
||||
}
|
||||
|
||||
$recipientEmail = $this->findRecipientEmail($addresses);
|
||||
$recipientEmail = $recipientEmailOverride !== null && $recipientEmailOverride !== ''
|
||||
? $recipientEmailOverride
|
||||
: $this->findRecipientEmail($addresses);
|
||||
if ($recipientEmail === '') {
|
||||
return ['success' => false, 'error' => 'Brak adresu e-mail kupujacego', 'log_id' => 0];
|
||||
return ['success' => false, 'error' => 'Brak adresu e-mail odbiorcy', 'log_id' => 0];
|
||||
}
|
||||
|
||||
$recipientName = $this->findRecipientName($addresses);
|
||||
$recipientName = $recipientNameOverride !== null && $recipientNameOverride !== ''
|
||||
? $recipientNameOverride
|
||||
: $this->findRecipientName($addresses);
|
||||
$companySettings = $this->loadCompanySettings();
|
||||
|
||||
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);
|
||||
|
||||
Reference in New Issue
Block a user