From 51ea2030e4de91ae366268dd52a2db7d767c2c25 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Wed, 25 Mar 2026 23:01:22 +0100 Subject: [PATCH] update --- .scannerwork/report-task.txt | 4 +- .vscode/ftp-kr.sync.cache.json | 94 +++- DOCS/ARCHITECTURE.md | 8 +- DOCS/TECH_CHANGELOG.md | 26 ++ public/assets/js/modules/automation-form.js | 34 ++ resources/views/automation/form.php | 78 +++- resources/views/settings/printing.php | 44 +- routes/web.php | 7 +- .../Automation/AutomationController.php | 56 ++- src/Modules/Automation/AutomationService.php | 427 +++++++++++++++++- src/Modules/Cron/CronHandlerFactory.php | 4 +- 11 files changed, 728 insertions(+), 54 deletions(-) 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: (int) $t['id'], 'name' => (string) ($t['name'] ?? '')]; }, $emailTemplates), JSON_UNESCAPED_UNICODE) ?>, + receiptConfigs: (int) ($cfg['id'] ?? 0), + 'name' => (string) ($cfg['name'] ?? ''), + 'number_format' => (string) ($cfg['number_format'] ?? '') + ]; + }, $receiptConfigs), JSON_UNESCAPED_UNICODE) ?>, recipientLabels: , + receiptIssueDateModes: , + receiptIssueDateModeLabels: , + receiptDuplicatePolicies: , + receiptDuplicatePolicyLabels: , shipmentStatusOptions: }; 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 ?? ''); -
+ - +
@@ -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) ); } }