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:
2026-03-18 00:39:47 +01:00
parent a6512cbfa4
commit b9f639e037
24 changed files with 4997 additions and 32 deletions

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