diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt
index 84d4dc7..fd2d494 100644
--- a/.scannerwork/report-task.txt
+++ b/.scannerwork/report-task.txt
@@ -2,5 +2,5 @@ projectKey=orderPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=orderPRO
-ceTaskId=ed32f370-62ab-459a-a255-d1d5b77526b2
-ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=ed32f370-62ab-459a-a255-d1d5b77526b2
+ceTaskId=37b32633-2562-4240-8b42-c6c993262727
+ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=37b32633-2562-4240-8b42-c6c993262727
diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json
index 9fac8ff..1fccda6 100644
--- a/.vscode/ftp-kr.sync.cache.json
+++ b/.vscode/ftp-kr.sync.cache.json
@@ -908,6 +908,12 @@
"size": 278,
"lmtime": 1774293990601,
"modified": false
+ },
+ "20260323_000070_create_delivery_status_mappings_table.sql": {
+ "type": "-",
+ "size": 538,
+ "lmtime": 1774304095531,
+ "modified": false
}
},
"seeders": {},
@@ -1024,6 +1030,18 @@
"lmtime": 1774296556289,
"modified": false
},
+ "fix_delivery_status.php": {
+ "type": "-",
+ "size": 356,
+ "lmtime": 1774302691828,
+ "modified": false
+ },
+ "fix_interval.php": {
+ "type": "-",
+ "size": 486,
+ "lmtime": 1774302992501,
+ "modified": false
+ },
".gitignore": {
"type": "-",
"size": 82,
@@ -2232,8 +2250,8 @@
"css": {
"app.css": {
"type": "-",
- "size": 53175,
- "lmtime": 1774295150587,
+ "size": 53388,
+ "lmtime": 1774304255719,
"modified": false
},
"app.css.map": {
@@ -2335,8 +2353,8 @@
},
"app.scss": {
"type": "-",
- "size": 42669,
- "lmtime": 1774295127572,
+ "size": 42711,
+ "lmtime": 1774304207044,
"modified": false
},
"login.css": {
@@ -2364,6 +2382,12 @@
"lmtime": 1773789611848,
"modified": false
},
+ "_delivery-status.scss": {
+ "type": "-",
+ "size": 802,
+ "lmtime": 1774294985916,
+ "modified": false
+ },
"_email-send.scss": {
"type": "-",
"size": 2093,
@@ -2382,10 +2406,10 @@
"lmtime": 1774219643850,
"modified": false
},
- "_delivery-status.scss": {
+ "_delivery-status-mappings.scss": {
"type": "-",
- "size": 802,
- "lmtime": 1774294985916,
+ "size": 212,
+ "lmtime": 1774304202952,
"modified": false
}
},
@@ -2433,8 +2457,8 @@
"layouts": {
"app.php": {
"type": "-",
- "size": 9146,
- "lmtime": 1773789621995,
+ "size": 9591,
+ "lmtime": 1774304236567,
"modified": false
},
"auth.php": {
@@ -2552,8 +2576,8 @@
},
"cron.php": {
"type": "-",
- "size": 7941,
- "lmtime": 1772992363118,
+ "size": 10176,
+ "lmtime": 1774302948459,
"modified": false
},
"database.php": {
@@ -2562,6 +2586,12 @@
"lmtime": 1772491513567,
"modified": false
},
+ "delivery-status-mappings.php": {
+ "type": "-",
+ "size": 6621,
+ "lmtime": 1774305162612,
+ "modified": false
+ },
"email-mailboxes.php": {
"type": "-",
"size": 9893,
@@ -2598,6 +2628,12 @@
"lmtime": 0,
"modified": false
},
+ "printing.php": {
+ "type": "-",
+ "size": 8683,
+ "lmtime": 0,
+ "modified": false
+ },
"products.php": {
"type": "-",
"size": 2225,
@@ -2652,8 +2688,8 @@
"routes": {
"web.php": {
"type": "-",
- "size": 23637,
- "lmtime": 1774285893715,
+ "size": 24661,
+ "lmtime": 1774305148776,
"modified": false
}
},
@@ -2693,8 +2729,8 @@
},
"project.yml": {
"type": "-",
- "size": 9068,
- "lmtime": 1774285628214,
+ "size": 9498,
+ "lmtime": 1774301665208,
"modified": false
}
},
@@ -2937,8 +2973,8 @@
},
"CronRepository.php": {
"type": "-",
- "size": 15418,
- "lmtime": 1772992332908,
+ "size": 16263,
+ "lmtime": 1774302741477,
"modified": false
},
"CronRunner.php": {
@@ -3281,8 +3317,14 @@
},
"CronSettingsController.php": {
"type": "-",
- "size": 4161,
- "lmtime": 1772992347512,
+ "size": 4710,
+ "lmtime": 1774302756544,
+ "modified": false
+ },
+ "DeliveryStatusMappingController.php": {
+ "type": "-",
+ "size": 8524,
+ "lmtime": 1774305143170,
"modified": false
},
"EmailMailboxController.php": {
@@ -3469,8 +3511,8 @@
},
"ApaczkaShipmentService.php": {
"type": "-",
- "size": 33310,
- "lmtime": 1774296789152,
+ "size": 33757,
+ "lmtime": 1774303090337,
"modified": false
},
"ApaczkaTrackingService.php": {
@@ -3479,10 +3521,16 @@
"lmtime": 1774294136946,
"modified": false
},
+ "DeliveryStatusMappingRepository.php": {
+ "type": "-",
+ "size": 4075,
+ "lmtime": 1774305131993,
+ "modified": false
+ },
"DeliveryStatus.php": {
"type": "-",
- "size": 10246,
- "lmtime": 1774296842023,
+ "size": 13599,
+ "lmtime": 1774304126768,
"modified": false
},
"InpostShipmentService.php": {
diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md
index ab4d53b..4c10efe 100644
--- a/DOCS/ARCHITECTURE.md
+++ b/DOCS/ARCHITECTURE.md
@@ -6,6 +6,12 @@
- Kolory akcji UI (przyciski `btn--primary` i warianty `btn--outline-primary`) sa odseparowane od koloru naglowkow (`section-title`) przez dedykowane tokeny `--c-action-primary` i `--c-action-primary-dark` w `resources/scss/shared/_ui-components.scss`.
- Import Allegro zapisuje log `import` z kontekstem triggera (`manual_import`, `orders_sync`, `status_sync`) i deduplikuje powtarzalne wpisy bez realnej zmiany.
- Automatyzacja obsluguje zdarzenie `shipment.status_changed` i warunek `shipment_status` oparty o statusy biznesowe.
+- Automatyzacja obsluguje akcje `issue_receipt` (Wystaw paragon) z parametrami: `receipt_config_id`, `issue_date_mode`, `duplicate_policy`.
+- Orkiestracja automatyzacji obsluguje chain events: akcja moze emitowac kolejne zdarzenie (`emitEvent`), a engine propaguje wspolny kontekst lancucha.
+- Zabezpieczenia chain automation (dla obecnych i przyszlych eventow):
+ - limit glebokosci lancucha (`MAX_CHAIN_DEPTH`),
+ - deduplikacja wykonania tej samej pary `event_type + rule_id` w obrebie jednego lancucha,
+ - limit historii wykonan w kontekście (`MAX_CHAIN_EXECUTIONS`).
- `ShipmentTrackingHandler` triggeruje automatyzacje tylko po zmianie `delivery_status` i przekazuje kontekst (`package_id`, `provider`, `delivery_status`, `delivery_status_raw`, `previous_status`).
- Kolejka wydruku ma akcje usuwania wpisu przez route `POST /settings/printing/jobs/delete` (CSRF + `OrderProAlerts.confirm`).
@@ -151,7 +157,7 @@
- `App\Modules\Accounting\AccountingController` (index — lista paragonow, export — XLSX)
- `App\Modules\Automation\AutomationController` (index, create, store, edit, update, destroy, toggleStatus)
- `App\Modules\Automation\AutomationRepository` (findAll, findById, create, update, delete, toggleActive, findActiveByEvent)
-- `App\Modules\Automation\AutomationService` (trigger, evaluateConditions, executeActions — watcher/executor regul automatyzacji; flow: ReceiptController::store() -> trigger('receipt.created') oraz ShipmentTrackingHandler::handle() -> trigger('shipment.status_changed', context) -> ewaluacja warunkow -> EmailSendingService::send())
+- `App\Modules\Automation\AutomationService` (trigger, evaluateConditions, executeActions — watcher/executor regul automatyzacji; flow: ReceiptController::store() -> trigger('receipt.created') oraz ShipmentTrackingHandler::handle() -> trigger('shipment.status_changed', context) -> ewaluacja warunkow -> EmailSendingService::send() / auto issue_receipt)
- `App\Modules\Shipments\ShipmentProviderInterface`
- `App\Modules\Shipments\ShipmentProviderRegistry`
- `App\Modules\Shipments\ApaczkaShipmentService`
diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md
index c3fc388..69356bc 100644
--- a/DOCS/TECH_CHANGELOG.md
+++ b/DOCS/TECH_CHANGELOG.md
@@ -1,5 +1,31 @@
# Tech Changelog
+## 2026-03-25 (Automation - new action "Wystaw paragon")
+- Dodano nowy typ akcji automatyzacji: `issue_receipt` (Wystaw paragon).
+- Konfiguracja akcji wymaga kompletu parametrow:
+ - `receipt_config_id` (aktywna konfiguracja paragonu),
+ - `issue_date_mode` (`today` / `order_date` / `payment_date`),
+ - `duplicate_policy` (`skip_if_exists` / `allow_duplicates`).
+- `AutomationController`:
+ - rozszerzono `ALLOWED_ACTION_TYPES`,
+ - dodano walidacje i parsowanie configu akcji `issue_receipt`,
+ - formularz dostaje aktywne konfiguracje paragonow i slowniki opcji.
+- `resources/views/automation/form.php` i `public/assets/js/modules/automation-form.js`:
+ - nowa pozycja akcji `Wystaw paragon`,
+ - dynamiczne pola dla parametrow akcji.
+- `AutomationService`:
+ - wykonuje automatyczne wystawienie paragonu przez `ReceiptRepository`,
+ - zapisuje activity log sukcesu/pominiecia/bledu,
+ - ma ochrone przed petla dla eventu `receipt.created` (akcja `issue_receipt` jest pomijana i logowana),
+ - obsluguje polityke duplikatow.
+- Aktualizacja DI:
+ - `routes/web.php` i `CronHandlerFactory` przekazuja do `AutomationService` zaleznosci `ReceiptRepository` i `ReceiptConfigRepository`.
+- Dodano systemowy mechanizm chain automation dla obecnych i przyszlych zdarzen:
+ - wspolny kontekst lancucha (`__automation_chain`) propagowany miedzy kolejnymi triggerami,
+ - `emitEvent(...)` jako bezpieczny mechanizm emitowania kolejnych eventow z akcji,
+ - ochrona anty-petla przez deduplikacje wykonania `event_type + rule_id` w jednym lancuchu,
+ - limit glebokosci lancucha (`MAX_CHAIN_DEPTH`) i limit historii wykonan (`MAX_CHAIN_EXECUTIONS`).
+
## 2026-03-25 (Phase 43 - Print Queue Entry Removal, Plan 01)
- Dodano usuwanie wpisu kolejki wydruku:
- `PrintJobRepository::deleteById(int): bool`,
diff --git a/public/assets/js/modules/automation-form.js b/public/assets/js/modules/automation-form.js
index 003893a..dad1655 100644
--- a/public/assets/js/modules/automation-form.js
+++ b/public/assets/js/modules/automation-form.js
@@ -66,6 +66,35 @@
return html;
}
+ function buildIssueReceiptActionConfig(namePrefix) {
+ var html = '';
+
+ html += '';
+
+ html += '';
+
+ return html;
+ }
+
function addCondition() {
var idx = getNextIndex(conditionsContainer);
var namePrefix = 'conditions[' + idx + ']';
@@ -99,6 +128,7 @@
row.innerHTML = '
'
+ '
'
+ '
'
+ buildEmailActionConfig(namePrefix)
@@ -137,6 +167,10 @@
if (select.value === 'send_email') {
configDiv.innerHTML = buildEmailActionConfig(namePrefix);
+ return;
+ }
+ if (select.value === 'issue_receipt') {
+ configDiv.innerHTML = buildIssueReceiptActionConfig(namePrefix);
}
}
diff --git a/resources/views/automation/form.php b/resources/views/automation/form.php
index 55e37ce..2df5cfb 100644
--- a/resources/views/automation/form.php
+++ b/resources/views/automation/form.php
@@ -16,6 +16,18 @@ $recipientLabels = [
'client_and_company' => 'Klient + e-mail z danych firmy',
'company' => 'E-mail z danych firmy',
];
+$receiptConfigs = is_array($receiptConfigs ?? null) ? $receiptConfigs : [];
+$receiptIssueDateModes = is_array($receiptIssueDateModes ?? null) ? $receiptIssueDateModes : [];
+$receiptDuplicatePolicies = is_array($receiptDuplicatePolicies ?? null) ? $receiptDuplicatePolicies : [];
+$receiptIssueDateModeLabels = [
+ 'today' => 'Data dzisiejsza',
+ 'order_date' => 'Data zamowienia',
+ 'payment_date' => 'Data platnosci (fallback: dzisiaj)',
+];
+$receiptDuplicatePolicyLabels = [
+ 'skip_if_exists' => 'Pomin jesli paragon juz istnieje',
+ 'allow_duplicates' => 'Wystawiaj kolejne paragony',
+];
$shipmentStatusOptions = is_array($shipmentStatusOptions ?? null) ? $shipmentStatusOptions : [];
?>
@@ -112,20 +124,51 @@ $shipmentStatusOptions = is_array($shipmentStatusOptions ?? null) ? $shipmentSta
-
-
-
+
+
+
+
+
+
+
+
+
@@ -150,7 +193,18 @@ window.AutomationFormData = {
emailTemplates: = json_encode(array_map(function($t) {
return ['id' => (int) $t['id'], 'name' => (string) ($t['name'] ?? '')];
}, $emailTemplates), JSON_UNESCAPED_UNICODE) ?>,
+ receiptConfigs: = json_encode(array_map(function($cfg) {
+ return [
+ 'id' => (int) ($cfg['id'] ?? 0),
+ 'name' => (string) ($cfg['name'] ?? ''),
+ 'number_format' => (string) ($cfg['number_format'] ?? '')
+ ];
+ }, $receiptConfigs), JSON_UNESCAPED_UNICODE) ?>,
recipientLabels: = json_encode($recipientLabels, JSON_UNESCAPED_UNICODE) ?>,
+ receiptIssueDateModes: = json_encode($receiptIssueDateModes, JSON_UNESCAPED_UNICODE) ?>,
+ receiptIssueDateModeLabels: = json_encode($receiptIssueDateModeLabels, JSON_UNESCAPED_UNICODE) ?>,
+ receiptDuplicatePolicies: = json_encode($receiptDuplicatePolicies, JSON_UNESCAPED_UNICODE) ?>,
+ receiptDuplicatePolicyLabels: = json_encode($receiptDuplicatePolicyLabels, JSON_UNESCAPED_UNICODE) ?>,
shipmentStatusOptions: = json_encode($shipmentStatusOptions, JSON_UNESCAPED_UNICODE) ?>
};
diff --git a/resources/views/settings/printing.php b/resources/views/settings/printing.php
index 71bd653..de4b11e 100644
--- a/resources/views/settings/printing.php
+++ b/resources/views/settings/printing.php
@@ -59,9 +59,9 @@ $currentStatusFilter = (string) ($printStatusFilter ?? '');
= $e((string) ($key['last_used_at'] ?? '-')) ?> |
= $e((string) ($key['created_at'] ?? '')) ?> |
-
|
@@ -177,11 +177,41 @@ $currentStatusFilter = (string) ($printStatusFilter ?? '');
var form = btn.closest('form');
if (!form) return;
if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
- window.OrderProAlerts.confirm(
- 'Usuwanie wpisu z kolejki',
- 'Czy na pewno chcesz usunac ten wpis kolejki wydruku?',
- function () { form.submit(); }
- );
+ window.OrderProAlerts.confirm({
+ title: 'Usuwanie wpisu z kolejki',
+ message: 'Czy na pewno chcesz usunac ten wpis kolejki wydruku?',
+ confirmLabel: 'Usun',
+ cancelLabel: 'Anuluj',
+ danger: true
+ }).then(function (confirmed) {
+ if (confirmed) {
+ form.submit();
+ }
+ });
+ } else {
+ form.submit();
+ }
+ });
+ });
+
+ document.querySelectorAll('.js-delete-api-key-btn').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var form = btn.closest('form');
+ if (!form) return;
+ if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
+ window.OrderProAlerts.confirm({
+ title: 'Usuwanie klucza API',
+ message: 'Czy na pewno chcesz usunac ten klucz API?',
+ confirmLabel: 'Usun',
+ cancelLabel: 'Anuluj',
+ danger: true
+ }).then(function (confirmed) {
+ if (confirmed) {
+ form.submit();
+ }
+ });
+ } else {
+ form.submit();
}
});
});
diff --git a/routes/web.php b/routes/web.php
index 5baf2e2..80330b5 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -233,7 +233,8 @@ return static function (Application $app): void {
$template,
$translator,
$auth,
- $automationRepository
+ $automationRepository,
+ $receiptConfigRepository
);
$variableResolver = new VariableResolver();
$attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template);
@@ -249,7 +250,9 @@ return static function (Application $app): void {
$automationRepository,
$emailSendingService,
new OrdersRepository($app->db()),
- $companySettingsRepository
+ $companySettingsRepository,
+ $receiptRepository,
+ $receiptConfigRepository
);
$printJobRepository = new PrintJobRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository);
diff --git a/src/Modules/Automation/AutomationController.php b/src/Modules/Automation/AutomationController.php
index ff67804..23131d4 100644
--- a/src/Modules/Automation/AutomationController.php
+++ b/src/Modules/Automation/AutomationController.php
@@ -10,14 +10,17 @@ use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
+use App\Modules\Settings\ReceiptConfigRepository;
use Throwable;
final class AutomationController
{
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.status_changed'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status'];
- private const ALLOWED_ACTION_TYPES = ['send_email'];
+ private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt'];
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'];
private const SHIPMENT_STATUS_OPTIONS = [
'registered' => ['label' => 'Przesylka zarejestrowana', 'statuses' => ['created', 'confirmed']],
'ready_for_pickup' => ['label' => 'Przesylka do odbioru', 'statuses' => ['ready_for_pickup']],
@@ -32,7 +35,8 @@ final class AutomationController
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
- private readonly AutomationRepository $repository
+ private readonly AutomationRepository $repository,
+ private readonly ReceiptConfigRepository $receiptConfigs
) {
}
@@ -194,6 +198,9 @@ final class AutomationController
'conditionTypes' => self::ALLOWED_CONDITION_TYPES,
'actionTypes' => self::ALLOWED_ACTION_TYPES,
'recipientOptions' => self::ALLOWED_RECIPIENTS,
+ 'receiptConfigs' => $this->listActiveReceiptConfigs(),
+ 'receiptIssueDateModes' => self::ALLOWED_RECEIPT_ISSUE_DATE_MODES,
+ 'receiptDuplicatePolicies' => self::ALLOWED_RECEIPT_DUPLICATE_POLICIES,
'shipmentStatusOptions' => self::SHIPMENT_STATUS_OPTIONS,
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
@@ -366,6 +373,51 @@ final class AutomationController
return ['template_id' => $templateId, 'recipient' => $recipient];
}
+ if ($type === 'issue_receipt') {
+ $configId = (int) ($action['receipt_config_id'] ?? 0);
+ $issueDateMode = (string) ($action['issue_date_mode'] ?? '');
+ $duplicatePolicy = (string) ($action['duplicate_policy'] ?? '');
+
+ if (
+ $configId <= 0
+ || !in_array($issueDateMode, self::ALLOWED_RECEIPT_ISSUE_DATE_MODES, true)
+ || !in_array($duplicatePolicy, self::ALLOWED_RECEIPT_DUPLICATE_POLICIES, true)
+ ) {
+ return null;
+ }
+
+ return [
+ 'receipt_config_id' => $configId,
+ 'issue_date_mode' => $issueDateMode,
+ 'duplicate_policy' => $duplicatePolicy,
+ ];
+ }
+
return null;
}
+
+ /**
+ * @return list
+ */
+ private function listActiveReceiptConfigs(): array
+ {
+ $all = $this->receiptConfigs->listAll();
+ $result = [];
+ foreach ($all as $config) {
+ if ((int) ($config['is_active'] ?? 0) !== 1) {
+ continue;
+ }
+ $configId = (int) ($config['id'] ?? 0);
+ if ($configId <= 0) {
+ continue;
+ }
+ $result[] = [
+ 'id' => $configId,
+ 'name' => (string) ($config['name'] ?? ''),
+ 'number_format' => (string) ($config['number_format'] ?? ''),
+ ];
+ }
+
+ return $result;
+ }
}
diff --git a/src/Modules/Automation/AutomationService.php b/src/Modules/Automation/AutomationService.php
index efd7e85..50fe0f8 100644
--- a/src/Modules/Automation/AutomationService.php
+++ b/src/Modules/Automation/AutomationService.php
@@ -3,13 +3,19 @@ declare(strict_types=1);
namespace App\Modules\Automation;
+use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Email\EmailSendingService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
+use App\Modules\Settings\ReceiptConfigRepository;
use Throwable;
final class AutomationService
{
+ private const CHAIN_STATE_KEY = '__automation_chain';
+ private const MAX_CHAIN_DEPTH = 8;
+ private const MAX_CHAIN_EXECUTIONS = 200;
+
private const SHIPMENT_STATUS_OPTION_MAP = [
'registered' => ['created', 'confirmed'],
'ready_for_pickup' => ['ready_for_pickup'],
@@ -24,7 +30,9 @@ final class AutomationService
private readonly AutomationRepository $repository,
private readonly EmailSendingService $emailService,
private readonly OrdersRepository $orders,
- private readonly CompanySettingsRepository $companySettings
+ private readonly CompanySettingsRepository $companySettings,
+ private readonly ReceiptRepository $receipts,
+ private readonly ReceiptConfigRepository $receiptConfigs
) {
}
@@ -33,6 +41,12 @@ final class AutomationService
*/
public function trigger(string $eventType, int $orderId, array $context = []): void
{
+ $chainState = $this->getChainState($context);
+ if ($chainState['depth'] > self::MAX_CHAIN_DEPTH) {
+ return;
+ }
+ $context = $this->withChainState($context, $chainState);
+
$rules = $this->repository->findActiveByEvent($eventType);
if ($rules === []) {
return;
@@ -47,12 +61,19 @@ final class AutomationService
foreach ($rules as $rule) {
try {
+ $ruleId = (int) ($rule['id'] ?? 0);
+ $executionKey = $this->buildExecutionKey($eventType, $ruleId);
+ if ($this->isExecutionAlreadyProcessed($chainState, $executionKey)) {
+ continue;
+ }
+
$conditions = is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$ruleName = (string) ($rule['name'] ?? '');
+ $ruleContext = $this->withExecution($context, $executionKey);
- if ($this->evaluateConditions($conditions, $order, $context)) {
- $this->executeActions($actions, $orderId, $ruleName);
+ if ($this->evaluateConditions($conditions, $order, $ruleContext)) {
+ $this->executeActions($actions, $orderId, $ruleName, $ruleContext);
}
} catch (Throwable) {
// Blad jednej reguly nie blokuje kolejnych
@@ -151,8 +172,9 @@ final class AutomationService
/**
* @param list> $actions
+ * @param array $context
*/
- private function executeActions(array $actions, int $orderId, string $ruleName): void
+ private function executeActions(array $actions, int $orderId, string $ruleName, array $context): void
{
foreach ($actions as $action) {
$type = (string) ($action['action_type'] ?? '');
@@ -160,6 +182,11 @@ final class AutomationService
if ($type === 'send_email') {
$this->handleSendEmail($config, $orderId, $ruleName);
+ continue;
+ }
+
+ if ($type === 'issue_receipt') {
+ $this->handleIssueReceipt($config, $orderId, $ruleName, $context);
}
}
}
@@ -214,4 +241,396 @@ final class AutomationService
$companyName
);
}
+
+ /**
+ * @param array $config
+ * @param array $context
+ */
+ private function handleIssueReceipt(array $config, int $orderId, string $ruleName, array $context): void
+ {
+ $actorName = 'Automatyzacja: ' . $ruleName;
+
+ $configId = (int) ($config['receipt_config_id'] ?? 0);
+ if ($configId <= 0) {
+ return;
+ }
+ $issueDateMode = (string) ($config['issue_date_mode'] ?? 'today');
+ $duplicatePolicy = (string) ($config['duplicate_policy'] ?? 'skip_if_exists');
+
+ $receiptConfig = $this->receiptConfigs->findById($configId);
+ if ($receiptConfig === null || (int) ($receiptConfig['is_active'] ?? 0) !== 1) {
+ $this->orders->recordActivity(
+ $orderId,
+ 'automation_receipt_failed',
+ $actorName . ' - nieprawidlowa lub nieaktywna konfiguracja paragonu',
+ ['receipt_config_id' => $configId],
+ 'system',
+ $actorName
+ );
+ return;
+ }
+
+ $existingReceipts = $this->receipts->findByOrderId($orderId);
+ if ($duplicatePolicy === 'skip_if_exists' && $existingReceipts !== []) {
+ $this->orders->recordActivity(
+ $orderId,
+ 'automation_receipt_skipped',
+ $actorName . ' - pomieto, paragon juz istnieje dla zamowienia',
+ ['duplicate_policy' => $duplicatePolicy, 'existing_count' => count($existingReceipts)],
+ 'system',
+ $actorName
+ );
+ return;
+ }
+
+ $details = $this->orders->findDetails($orderId);
+ if ($details === null) {
+ return;
+ }
+ $order = is_array($details['order'] ?? null) ? $details['order'] : [];
+ $items = is_array($details['items'] ?? null) ? $details['items'] : [];
+ $addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
+ $payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
+
+ $issueDate = $this->resolveIssueDate($issueDateMode, $order, $payments);
+ $saleDate = $this->resolveSaleDate($receiptConfig, $order, $payments, $issueDate);
+ $orderReference = $this->resolveOrderReference($receiptConfig, $order);
+ $sellerSnapshot = $this->buildSellerSnapshot();
+ $buyerSnapshot = $this->buildBuyerSnapshot($addresses);
+ ['items' => $itemsSnapshot, 'total_gross' => $totalGross] = $this->buildItemsSnapshot($items);
+
+ try {
+ $receiptNumber = $this->receipts->getNextNumber(
+ $configId,
+ (string) ($receiptConfig['number_format'] ?? 'PAR/%N/%M/%Y'),
+ (string) ($receiptConfig['numbering_type'] ?? 'monthly')
+ );
+ $this->receipts->create([
+ 'order_id' => $orderId,
+ 'config_id' => $configId,
+ 'receipt_number' => $receiptNumber,
+ 'issue_date' => $issueDate,
+ 'sale_date' => $saleDate,
+ 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
+ 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
+ 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
+ 'total_net' => number_format($totalGross, 2, '.', ''),
+ 'total_gross' => number_format($totalGross, 2, '.', ''),
+ 'order_reference_value' => $orderReference,
+ 'created_by' => null,
+ ]);
+
+ $this->orders->recordActivity(
+ $orderId,
+ 'receipt_issued',
+ 'Wystawiono paragon: ' . $receiptNumber,
+ ['receipt_number' => $receiptNumber, 'config_id' => $configId, 'total_gross' => number_format($totalGross, 2, '.', '')],
+ 'system',
+ $actorName
+ );
+
+ // Chain automation: issuing receipt from one rule should trigger
+ // rules listening on receipt.created. Uses generic chain context
+ // with depth + rule deduplication to prevent loops.
+ $this->emitEvent(
+ 'receipt.created',
+ $orderId,
+ $context,
+ [
+ 'automation_source' => 'issue_receipt',
+ 'automation_rule' => $ruleName,
+ 'receipt_number' => $receiptNumber,
+ 'receipt_config_id' => $configId,
+ ]
+ );
+ } catch (Throwable $exception) {
+ $this->orders->recordActivity(
+ $orderId,
+ 'automation_receipt_failed',
+ $actorName . ' - blad wystawiania paragonu',
+ ['error' => $exception->getMessage(), 'receipt_config_id' => $configId],
+ 'system',
+ $actorName
+ );
+ }
+ }
+
+ /**
+ * @param array $order
+ * @param list> $payments
+ */
+ private function resolveIssueDate(string $mode, array $order, array $payments): string
+ {
+ if ($mode === 'order_date') {
+ $orderedAt = trim((string) ($order['ordered_at'] ?? ''));
+ if ($orderedAt !== '') {
+ $timestamp = strtotime($orderedAt);
+ if ($timestamp !== false) {
+ return date('Y-m-d', $timestamp);
+ }
+ }
+ }
+
+ if ($mode === 'payment_date') {
+ $firstPayment = $payments[0] ?? [];
+ $paymentDate = trim((string) ($firstPayment['payment_date'] ?? ''));
+ if ($paymentDate !== '') {
+ $timestamp = strtotime($paymentDate);
+ if ($timestamp !== false) {
+ return date('Y-m-d', $timestamp);
+ }
+ }
+ }
+
+ return date('Y-m-d');
+ }
+
+ /**
+ * @param array $receiptConfig
+ * @param array $order
+ * @param list> $payments
+ */
+ private function resolveSaleDate(array $receiptConfig, array $order, array $payments, string $issueDate): string
+ {
+ $source = (string) ($receiptConfig['sale_date_source'] ?? 'issue_date');
+
+ if ($source === 'order_date') {
+ $ordered = (string) ($order['ordered_at'] ?? '');
+ if ($ordered !== '') {
+ $ts = strtotime($ordered);
+ return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
+ }
+ }
+
+ if ($source === 'payment_date' && $payments !== []) {
+ $lastPayment = $payments[0] ?? [];
+ $payDate = (string) ($lastPayment['payment_date'] ?? '');
+ if ($payDate !== '') {
+ $ts = strtotime($payDate);
+ return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
+ }
+ }
+
+ return $issueDate;
+ }
+
+ /**
+ * @param array $receiptConfig
+ * @param array $order
+ */
+ private function resolveOrderReference(array $receiptConfig, array $order): ?string
+ {
+ $ref = (string) ($receiptConfig['order_reference'] ?? 'none');
+
+ if ($ref === 'orderpro') {
+ return (string) ($order['internal_order_number'] ?? '');
+ }
+
+ if ($ref === 'integration') {
+ return (string) ($order['external_order_id'] ?? '');
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ private function buildSellerSnapshot(): array
+ {
+ $seller = $this->companySettings->getSettings();
+
+ return [
+ 'company_name' => $seller['company_name'] ?? '',
+ 'tax_number' => $seller['tax_number'] ?? '',
+ 'street' => $seller['street'] ?? '',
+ 'city' => $seller['city'] ?? '',
+ 'postal_code' => $seller['postal_code'] ?? '',
+ 'phone' => $seller['phone'] ?? '',
+ 'email' => $seller['email'] ?? '',
+ 'bank_account' => $seller['bank_account'] ?? '',
+ 'bdo_number' => $seller['bdo_number'] ?? '',
+ 'regon' => $seller['regon'] ?? '',
+ 'court_register' => $seller['court_register'] ?? '',
+ ];
+ }
+
+ /**
+ * @param list> $addresses
+ * @return array|null
+ */
+ private function buildBuyerSnapshot(array $addresses): ?array
+ {
+ $buyerAddress = $this->resolveBuyerAddress($addresses);
+ if ($buyerAddress === null) {
+ return null;
+ }
+
+ return [
+ 'name' => $buyerAddress['name'] ?? '',
+ 'company_name' => $buyerAddress['company_name'] ?? '',
+ 'tax_number' => $buyerAddress['company_tax_number'] ?? '',
+ 'street' => trim((string) (($buyerAddress['street_name'] ?? '') . ' ' . ($buyerAddress['street_number'] ?? ''))),
+ 'city' => $buyerAddress['city'] ?? '',
+ 'postal_code' => $buyerAddress['zip_code'] ?? '',
+ 'phone' => $buyerAddress['phone'] ?? '',
+ 'email' => $buyerAddress['email'] ?? '',
+ ];
+ }
+
+ /**
+ * @param list> $addresses
+ * @return array|null
+ */
+ private function resolveBuyerAddress(array $addresses): ?array
+ {
+ $byType = [];
+ foreach ($addresses as $address) {
+ $type = (string) ($address['address_type'] ?? '');
+ if ($type !== '' && !isset($byType[$type])) {
+ $byType[$type] = $address;
+ }
+ }
+
+ return $byType['invoice'] ?? $byType['customer'] ?? null;
+ }
+
+ /**
+ * @param list> $items
+ * @return array{items:list>,total_gross:float}
+ */
+ private function buildItemsSnapshot(array $items): array
+ {
+ $itemsSnapshot = [];
+ $totalGross = 0.0;
+ foreach ($items as $item) {
+ $qty = (float) ($item['quantity'] ?? 0);
+ $price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
+ $lineTotal = $qty * $price;
+ $totalGross += $lineTotal;
+ $itemsSnapshot[] = [
+ 'name' => $item['original_name'] ?? '',
+ 'quantity' => $qty,
+ 'price' => $price,
+ 'total' => $lineTotal,
+ 'sku' => $item['sku'] ?? '',
+ 'ean' => $item['ean'] ?? '',
+ ];
+ }
+
+ return [
+ 'items' => $itemsSnapshot,
+ 'total_gross' => $totalGross,
+ ];
+ }
+
+ /**
+ * @param array $context
+ * @return array{chain_id:string,depth:int,executions:list}
+ */
+ private function getChainState(array $context): array
+ {
+ $raw = $context[self::CHAIN_STATE_KEY] ?? null;
+ $rawState = is_array($raw) ? $raw : [];
+
+ $chainId = trim((string) ($rawState['chain_id'] ?? ''));
+ if ($chainId === '') {
+ $chainId = $this->generateChainId();
+ }
+
+ $depth = max(0, (int) ($rawState['depth'] ?? 0));
+
+ $executionsRaw = $rawState['executions'] ?? [];
+ $executions = [];
+ if (is_array($executionsRaw)) {
+ foreach ($executionsRaw as $executionRaw) {
+ $execution = trim((string) $executionRaw);
+ if ($execution === '') {
+ continue;
+ }
+ $executions[] = $execution;
+ }
+ }
+
+ return [
+ 'chain_id' => $chainId,
+ 'depth' => $depth,
+ 'executions' => array_values(array_unique($executions)),
+ ];
+ }
+
+ /**
+ * @param array $context
+ * @param array{chain_id:string,depth:int,executions:list} $state
+ * @return array
+ */
+ private function withChainState(array $context, array $state): array
+ {
+ $context[self::CHAIN_STATE_KEY] = [
+ 'chain_id' => $state['chain_id'],
+ 'depth' => $state['depth'],
+ 'executions' => $state['executions'],
+ ];
+
+ return $context;
+ }
+
+ /**
+ * @param array $context
+ * @return array
+ */
+ private function withExecution(array $context, string $executionKey): array
+ {
+ $state = $this->getChainState($context);
+ $state['executions'][] = $executionKey;
+ if (count($state['executions']) > self::MAX_CHAIN_EXECUTIONS) {
+ $state['executions'] = array_slice($state['executions'], -self::MAX_CHAIN_EXECUTIONS);
+ }
+
+ return $this->withChainState($context, $state);
+ }
+
+ private function buildExecutionKey(string $eventType, int $ruleId): string
+ {
+ if ($ruleId <= 0) {
+ return $eventType . '#unknown';
+ }
+
+ return $eventType . '#' . $ruleId;
+ }
+
+ /**
+ * @param array{chain_id:string,depth:int,executions:list} $state
+ */
+ private function isExecutionAlreadyProcessed(array $state, string $executionKey): bool
+ {
+ return in_array($executionKey, $state['executions'], true);
+ }
+
+ /**
+ * @param array $parentContext
+ * @param array $eventContext
+ */
+ private function emitEvent(string $eventType, int $orderId, array $parentContext, array $eventContext): void
+ {
+ $parentState = $this->getChainState($parentContext);
+ if ($parentState['depth'] >= self::MAX_CHAIN_DEPTH) {
+ return;
+ }
+
+ $childState = $parentState;
+ $childState['depth'] = $parentState['depth'] + 1;
+
+ $childContext = $this->withChainState($eventContext, $childState);
+ $this->trigger($eventType, $orderId, $childContext);
+ }
+
+ private function generateChainId(): string
+ {
+ try {
+ return bin2hex(random_bytes(8));
+ } catch (Throwable) {
+ return uniqid('chain_', true);
+ }
+ }
}
diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php
index 50f1acd..da0ae21 100644
--- a/src/Modules/Cron/CronHandlerFactory.php
+++ b/src/Modules/Cron/CronHandlerFactory.php
@@ -184,7 +184,9 @@ final class CronHandlerFactory
$automationRepository,
$emailService,
$ordersRepository,
- $companySettingsRepository
+ $companySettingsRepository,
+ new ReceiptRepository($this->db),
+ new ReceiptConfigRepository($this->db)
);
}
}