diff --git a/.vscode/ftp-kr.json b/.vscode/ftp-kr.json index 9dcbdcee..d3895fef 100644 --- a/.vscode/ftp-kr.json +++ b/.vscode/ftp-kr.json @@ -15,6 +15,7 @@ "/.vscode", "/.serena", "/.claude", - "CLAUDE.md" + "CLAUDE.md", + "/changelog" ] } \ No newline at end of file diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 9cefd960..15339653 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -97,6 +97,18 @@ "modified": false }, "files": {}, + "ga-db.txt": { + "type": "-", + "size": 2604, + "lmtime": 0, + "modified": false + }, + "ga.txt": { + "type": "-", + "size": 4111, + "lmtime": 0, + "modified": false + }, ".gitignore": { "type": "-", "size": 7, diff --git a/CLAUDE.md b/CLAUDE.md index 7a6e88be..36ec1367 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,6 @@ When creating or modifying overrides, PrestaShop also needs to rebuild the class ## Custom Assistant Command -- If the user writes `zapisz-changelog`, create or update monthly changelog file `changelog/YYYY-MM.md` (based on current date). +- If the user writes `zapisz-changelog`, create or update monthly changelog file `changelog/YYYY-MM-DD.md` (based on current date). - Add an entry for the current day with a concise summary of code changes made in the current session. - Include touched file paths and relevant line references where possible. diff --git a/changelog/2026-03.md b/changelog/2026-03-19.md similarity index 100% rename from changelog/2026-03.md rename to changelog/2026-03-19.md diff --git a/changelog/2026-03-21.md b/changelog/2026-03-21.md new file mode 100644 index 00000000..5224dc1f --- /dev/null +++ b/changelog/2026-03-21.md @@ -0,0 +1,15 @@ +# 2026-03-21 + +## Zmiany + +- Wywołano komendę `zapisz-changelog`. +- W repo są aktywne zmiany: rozbudowa `modules/empikmarketplace/src/Processor/OrderProcessor.php` (powiadomienia o błędach importu EMPIK: wielu odbiorców, deduplikacja wysyłki, zapis rejestru powiadomień), dodanie `modules/empikmarketplace/data/import_error_notifications.json`, aktualizacja `CLAUDE.md` (format `zapisz-changelog` na `YYYY-MM-DD`) oraz przejście z `changelog/2026-03.md` na pliki dzienne (`changelog/2026-03-19.md`, `changelog/2026-03-21.md`). + +## Zmienione pliki + +- `CLAUDE.md` +- `changelog/2026-03.md` (usunięty) +- `changelog/2026-03-19.md` (nowy) +- `changelog/2026-03-21.md` +- `modules/empikmarketplace/data/import_error_notifications.json` (nowy) +- `modules/empikmarketplace/src/Processor/OrderProcessor.php` diff --git a/modules/empikmarketplace/data/import_error_notifications.json b/modules/empikmarketplace/data/import_error_notifications.json new file mode 100644 index 00000000..803610f9 --- /dev/null +++ b/modules/empikmarketplace/data/import_error_notifications.json @@ -0,0 +1,5 @@ +{ + "40102298078155-A": { + "sent_at": "2026-03-21 18:41:03" + } +} \ No newline at end of file diff --git a/modules/empikmarketplace/src/Processor/OrderProcessor.php b/modules/empikmarketplace/src/Processor/OrderProcessor.php index 66828058..ee3ea783 100644 --- a/modules/empikmarketplace/src/Processor/OrderProcessor.php +++ b/modules/empikmarketplace/src/Processor/OrderProcessor.php @@ -20,7 +20,12 @@ class OrderProcessor { const CODE_WAITING_ACCEPTANCE = 'WAITING_ACCEPTANCE'; const CODE_SHIPPING = 'SHIPPING'; - const ERROR_NOTIFICATION_EMAIL = 'jacek.pyziak@project-pro.pl'; + const ERROR_NOTIFICATION_EMAILS = [ + 'jacek.pyziak@project-pro.pl', + 'biuro@interblue.pl', + 'wxkwasnik@gmail.com', + ]; + const ERROR_NOTIFICATION_STORE_FILE = 'import_error_notifications.json'; /** @var EmpikClientFactory */ protected $empikClientFactory; @@ -138,35 +143,191 @@ class OrderProcessor protected function sendFailureNotification($empikOrderId, Exception $exception, array $orderData = []) { try { + $empikNotificationOrderId = $this->resolveEmpikNotificationOrderId($empikOrderId, $orderData); + + if ($this->wasFailureNotificationSent($empikNotificationOrderId)) { + $this->logger->logError(sprintf( + 'Skipping duplicate EMPIK import error notification for order [%s] - notification already sent.', + $empikNotificationOrderId + )); + + return; + } + $shopName = Configuration::get('PS_SHOP_NAME'); $subject = sprintf('[%s] Blad importu zamowienia EMPIK: %s', $shopName, $empikOrderId); $orderContext = $this->buildOrderContext($orderData); + $templateVars = [ + '{empik_order_id}' => $empikOrderId, + '{error_message}' => $exception->getMessage(), + '{error_date}' => date('Y-m-d H:i:s'), + '{order_context}' => $orderContext, + '{order_context_html}' => nl2br(htmlspecialchars($orderContext, ENT_QUOTES, 'UTF-8')), + '{stack_trace}' => nl2br($exception->getTraceAsString()), + ]; - Mail::send( - (int) Configuration::get('PS_LANG_DEFAULT'), - 'empik_import_error', - $subject, - [ - '{empik_order_id}' => $empikOrderId, - '{error_message}' => $exception->getMessage(), - '{error_date}' => date('Y-m-d H:i:s'), - '{order_context}' => $orderContext, - '{order_context_html}' => nl2br(htmlspecialchars($orderContext, ENT_QUOTES, 'UTF-8')), - '{stack_trace}' => nl2br($exception->getTraceAsString()), - ], - self::ERROR_NOTIFICATION_EMAIL, - null, - Configuration::get('PS_SHOP_EMAIL'), - $shopName, - null, - null, - _PS_MODULE_DIR_ . 'empikmarketplace/mails/' - ); + $notificationSent = false; + foreach ($this->getErrorNotificationEmails() as $recipientEmail) { + $sent = Mail::send( + (int) Configuration::get('PS_LANG_DEFAULT'), + 'empik_import_error', + $subject, + $templateVars, + $recipientEmail, + null, + Configuration::get('PS_SHOP_EMAIL'), + $shopName, + null, + null, + _PS_MODULE_DIR_ . 'empikmarketplace/mails/' + ); + + if (!$sent) { + $this->logger->logError(sprintf( + 'Failed to send EMPIK import error notification for order [%s] to [%s].', + $empikOrderId, + $recipientEmail + )); + continue; + } + + $notificationSent = true; + } + + if ($notificationSent) { + $this->markFailureNotificationAsSent($empikNotificationOrderId); + } else { + $this->logger->logError(sprintf( + 'EMPIK import error notification was not sent to any recipient for order [%s].', + $empikOrderId + )); + } } catch (Exception $e) { $this->logger->logError(sprintf('Error sending failure notification email: %s', $e->getMessage())); } } + /** + * @return array + */ + protected function getErrorNotificationEmails() + { + return self::ERROR_NOTIFICATION_EMAILS; + } + + /** + * Deduplication key must always be EMPIK order_id from API payload. + * + * @param string $fallbackOrderId + * @param array $orderData + * @return string + */ + protected function resolveEmpikNotificationOrderId($fallbackOrderId, array $orderData) + { + if (!empty($orderData['order_id']) && is_string($orderData['order_id'])) { + return $orderData['order_id']; + } + + if (!empty($fallbackOrderId) && $fallbackOrderId !== 'unknown') { + return $fallbackOrderId; + } + + return ''; + } + + /** + * @param string $empikOrderId + * @return bool + */ + protected function wasFailureNotificationSent($empikOrderId) + { + if (!$empikOrderId) { + return false; + } + + $notifications = $this->loadNotificationRegistry(); + + return isset($notifications[$empikOrderId]); + } + + /** + * @param string $empikOrderId + * @return void + */ + protected function markFailureNotificationAsSent($empikOrderId) + { + if (!$empikOrderId) { + return; + } + + $notifications = $this->loadNotificationRegistry(); + $notifications[$empikOrderId] = [ + 'sent_at' => date('Y-m-d H:i:s'), + ]; + + $this->saveNotificationRegistry($notifications); + } + + /** + * @return string + */ + protected function getNotificationRegistryPath() + { + return _PS_MODULE_DIR_ . 'empikmarketplace/data/' . self::ERROR_NOTIFICATION_STORE_FILE; + } + + /** + * @return array + */ + protected function loadNotificationRegistry() + { + $path = $this->getNotificationRegistryPath(); + + if (!file_exists($path)) { + return []; + } + + $raw = @file_get_contents($path); + if ($raw === false || $raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + + return $decoded; + } + + /** + * @param array $notifications + * @return void + */ + protected function saveNotificationRegistry(array $notifications) + { + $path = $this->getNotificationRegistryPath(); + $directory = dirname($path); + + if (!is_dir($directory)) { + @mkdir($directory, 0755, true); + } + + $json = json_encode($notifications, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $this->logger->logError('Unable to serialize EMPIK import error notification registry.'); + + return; + } + + if (@file_put_contents($path, $json, LOCK_EX) === false) { + $this->logger->logError(sprintf( + 'Unable to save EMPIK import error notification registry to [%s].', + $path + )); + } + } + /** * @param array $orderData * @return string @@ -178,14 +339,35 @@ class OrderProcessor } $customer = isset($orderData['customer']) && is_array($orderData['customer']) ? $orderData['customer'] : []; + $shippingAddress = isset($customer['shipping_address']) && is_array($customer['shipping_address']) ? $customer['shipping_address'] : []; + $billingAddress = isset($customer['billing_address']) && is_array($customer['billing_address']) ? $customer['billing_address'] : []; + $additionalFields = isset($orderData['order_additional_fields']) && is_array($orderData['order_additional_fields']) ? $orderData['order_additional_fields'] : []; + $customerEmail = $this->extractAdditionalFieldValue($additionalFields, 'customer-email'); + $summary = [ 'order_id' => isset($orderData['order_id']) ? $orderData['order_id'] : null, 'order_state' => isset($orderData['order_state']) ? $orderData['order_state'] : null, + 'created_date' => isset($orderData['created_date']) ? $orderData['created_date'] : null, + 'updated_date' => isset($orderData['updated_date']) ? $orderData['updated_date'] : null, + 'currency_iso_code' => isset($orderData['currency_iso_code']) ? $orderData['currency_iso_code'] : null, + 'payment_type' => isset($orderData['payment_type']) ? $orderData['payment_type'] : null, + 'shipping_type_code' => isset($orderData['shipping_type_code']) ? $orderData['shipping_type_code'] : null, + 'shipping_type_label' => isset($orderData['shipping_type_label']) ? $orderData['shipping_type_label'] : null, 'total_price' => isset($orderData['total_price']) ? $orderData['total_price'] : null, 'shipping_price' => isset($orderData['shipping_price']) ? $orderData['shipping_price'] : null, 'order_lines_count' => isset($orderData['order_lines']) && is_array($orderData['order_lines']) ? count($orderData['order_lines']) : 0, - 'has_shipping_address' => !empty($customer['shipping_address']) ? 1 : 0, - 'has_billing_address' => !empty($customer['billing_address']) ? 1 : 0, + 'customer_email' => $customerEmail, + 'has_shipping_address' => !empty($shippingAddress) ? 1 : 0, + 'has_billing_address' => !empty($billingAddress) ? 1 : 0, + 'customer' => [ + 'firstname' => isset($customer['firstname']) ? $customer['firstname'] : null, + 'lastname' => isset($customer['lastname']) ? $customer['lastname'] : null, + 'email' => $customerEmail, + ], + 'shipping_address' => $this->buildAddressContext($shippingAddress), + 'billing_address' => $this->buildAddressContext($billingAddress), + 'order_lines' => $this->buildOrderLinesContext(isset($orderData['order_lines']) && is_array($orderData['order_lines']) ? $orderData['order_lines'] : []), + 'order_additional_fields' => $this->buildAdditionalFieldsContext($additionalFields), ]; $json = json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); @@ -193,6 +375,96 @@ class OrderProcessor return $json !== false ? $json : 'Unable to serialize order payload summary.'; } + /** + * @param array $addressData + * @return array|null + */ + protected function buildAddressContext(array $addressData) + { + if (empty($addressData)) { + return null; + } + + return [ + 'firstname' => isset($addressData['firstname']) ? $addressData['firstname'] : null, + 'lastname' => isset($addressData['lastname']) ? $addressData['lastname'] : null, + 'company' => isset($addressData['company']) ? $addressData['company'] : null, + 'street_1' => isset($addressData['street_1']) ? $addressData['street_1'] : null, + 'street_2' => isset($addressData['street_2']) ? $addressData['street_2'] : null, + 'city' => isset($addressData['city']) ? $addressData['city'] : null, + 'zip_code' => isset($addressData['zip_code']) ? $addressData['zip_code'] : null, + 'country_iso_code' => isset($addressData['country_iso_code']) ? $addressData['country_iso_code'] : null, + 'phone' => isset($addressData['phone']) ? $addressData['phone'] : null, + 'additional_info' => isset($addressData['additional_info']) ? $addressData['additional_info'] : null, + ]; + } + + /** + * @param array $orderLines + * @return array + */ + protected function buildOrderLinesContext(array $orderLines) + { + $context = []; + + foreach ($orderLines as $line) { + $context[] = [ + 'order_line_id' => isset($line['order_line_id']) ? $line['order_line_id'] : null, + 'offer_sku' => isset($line['offer_sku']) ? $line['offer_sku'] : null, + 'offer_id' => isset($line['offer_id']) ? $line['offer_id'] : null, + 'product_title' => isset($line['product_title']) ? $line['product_title'] : null, + 'quantity' => isset($line['quantity']) ? $line['quantity'] : null, + 'price_unit' => isset($line['price_unit']) ? $line['price_unit'] : null, + 'price' => isset($line['price']) ? $line['price'] : null, + ]; + } + + return $context; + } + + /** + * @param array $additionalFields + * @return array + */ + protected function buildAdditionalFieldsContext(array $additionalFields) + { + $context = []; + + foreach ($additionalFields as $field) { + if (!is_array($field)) { + continue; + } + + $context[] = [ + 'code' => isset($field['code']) ? $field['code'] : null, + 'type' => isset($field['type']) ? $field['type'] : null, + 'value' => isset($field['value']) ? $field['value'] : null, + ]; + } + + return $context; + } + + /** + * @param array $additionalFields + * @param string $code + * @return string|null + */ + protected function extractAdditionalFieldValue(array $additionalFields, $code) + { + foreach ($additionalFields as $field) { + if (!is_array($field)) { + continue; + } + + if (isset($field['code']) && $field['code'] === $code) { + return isset($field['value']) ? $field['value'] : null; + } + } + + return null; + } + protected function accept(EmpikOrderWrapper $empikOrder) { $acceptLines = $empikOrder->getAcceptanceLines();