Compare commits

..

3 Commits

Author SHA1 Message Date
Jacek
d3e3724cfb fix: Apilo email z danymi zamówienia + infinite retry co 30 min dla order jobów
- Email notyfikacji zawiera numer zamówienia, klienta, datę, kwotę
- Order joby (send_order, sync_payment, sync_status) ponawiane w nieskończoność co 30 min
- Rozróżnienie PONAWIANY vs TRWAŁY BŁĄD w emailu
- Cleanup stuck jobów po udanym wysłaniu zamówienia
- +2 testy infinite retry w CronJobRepositoryTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:23:02 +01:00
Jacek
45e768d083 update 2026-03-16 10:01:47 +01:00
Jacek
7f85a57c0d build: ver_0.341 - bugfix wysyłka zamówień do Apilo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:00:25 +01:00
18 changed files with 761 additions and 27 deletions

View File

@@ -35,6 +35,7 @@ Status: Planning
|-------|------|-------|--------|-----------|
| 7 | Coupon Fatal Error — order placement crash | 1 | Done | 2026-03-15 |
| 8 | Apilo orders not sending — diagnoza i naprawa | 1 | Done | 2026-03-16 |
| 9 | Apilo email notification + infinite retry | 1 | Done | 2026-03-19 |
## Phase Details
@@ -66,4 +67,4 @@ Status: Planning
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-16*
*Last updated: 2026-03-19*

View File

@@ -5,25 +5,25 @@
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Właściciel sklepu ma pełną kontrolę nad sprzedażą online w jednym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
**Current focus:** Hotfix Apilo — COMPLETE
**Current focus:** Phase 9 complete — Apilo email fix + infinite retry
## Current Position
Milestone: Hotfix — Apilo orders not sending
Phase: 8Diagnoza i naprawa wysyłki zamówień do Apilo — Complete
Plan: 08-01 complete (phase done)
Status: UNIFY complete, phase 8 finished
Last activity: 2026-03-16 — 08-01 UNIFY complete
Milestone: Hotfix
Phase: 9Apilo email notification + infinite retry — Complete
Plan: 09-01 complete (phase done)
Status: UNIFY complete, phase 9 finished
Last activity: 2026-03-19 — 09-01 UNIFY complete
Progress:
- Phase 8: [██████████] 100% (COMPLETE)
- Phase 9: [██████████] 100% (COMPLETE)
## Loop Position
Current loop state (phase 8, plan 01):
Current loop state (phase 9, plan 01):
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 8 complete]
✓ ✓ ✓ [Phase 9 complete]
```
Previous phases:
@@ -33,6 +33,7 @@ Phase 5: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-0
Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
Phase 9: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
```
## Accumulated Context
@@ -42,6 +43,9 @@ Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-0
- 2026-03-16: Przyczyna braku wysyłki = brakujące $apiloRepository w use() closures cron.php (regresja z fazy 6)
- 2026-03-16: Retry -1 orders co 1h zamiast permanent failure
- 2026-03-16: Email notification o trwale failed Apilo jobach
- 2026-03-19: Order-related Apilo joby — infinite retry co 30 min (nigdy permanent failure)
- 2026-03-19: Email z danymi zamówienia + rozróżnienie PONAWIANY vs TRWAŁY BŁĄD
- 2026-03-19: Cleanup stuck sync_payment/sync_status jobów po udanym wysłaniu
### Deferred Issues
None.
@@ -51,10 +55,10 @@ None.
## Session Continuity
Last session: 2026-03-16
Stopped at: Phase 08 UNIFY complete — Apilo fix loop closed
Next action: Deploy fix to instance, then /paul:progress for next work
Resume file: .paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
Last session: 2026-03-19
Stopped at: Phase 09 UNIFY complete
Next action: Deploy fix or /paul:progress for next work
Resume file: .paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,201 @@
---
phase: 09-apilo-email-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [cron.php, autoload/Domain/CronJob/CronJobRepository.php, autoload/Domain/CronJob/CronJobType.php]
autonomous: true
---
<objective>
## Goal
1. Wzbogacić email notyfikacji o trwałym błędzie Apilo o czytelne dane zamówienia (numer, klient, kwota)
2. Zamówienia Apilo (send_order, sync_payment, sync_status) muszą być ponawiane w nieskończoność co 30 minut
3. Email o błędzie nadal wysyłany (jako ostrzeżenie), ale job wraca do pending zamiast permanent failure
4. Po udanym wysłaniu zamówienia — czyścimy powiązane failed/pending joby
## Purpose
Administrator dostaje email bez informacji o którym zamówieniu chodzi. Dodatkowo, po 10 próbach zamówienie przestaje być synchronizowane — to niedopuszczalne, bo zamówienie musi trafić do Apilo.
## Output
- Zmodyfikowany `cron.php` — lepsza treść emaila + czyszczenie jobów po sukcesie
- Zmodyfikowany `CronJobRepository` — obsługa infinite retry
- Zmodyfikowany `CronJobType` — stała backoffu 30min dla Apilo
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
## Prior Work
@.paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
## Source Files
@cron.php (linie 198-529 — handler APILO_SEND_ORDER, linie 763-781 — email notification)
@autoload/Domain/CronJob/CronJobRepository.php (markFailed — linie 131-156)
@autoload/Domain/CronJob/CronJobType.php (stałe backoff)
</context>
<acceptance_criteria>
## AC-1: Email zawiera czytelne dane zamówienia
```gherkin
Given trwale nieudane zadanie Apilo z payload zawierającym order_id
When system wysyła email notyfikacji
Then email zawiera: numer zamówienia, dane klienta, datę zamówienia, kwotę
And temat emaila zawiera numery zamówień
```
## AC-2: Brak order_id w payload nie powoduje błędu
```gherkin
Given trwale nieudane zadanie Apilo bez order_id w payload (np. apilo_token_keepalive)
When system wysyła email notyfikacji
Then email wyświetla dane job-a bez sekcji zamówienia, bez błędów
```
## AC-3: Joby zamówień Apilo ponawiają się w nieskończoność co 30 minut
```gherkin
Given job typu apilo_send_order, apilo_sync_payment lub apilo_sync_status
When job osiąga max_attempts
Then job NIE jest oznaczany jako failed
And job wraca do pending ze scheduled_at = now + 30 minut
And email ostrzegawczy jest wysyłany (z informacją że job dalej jest ponawiany)
```
## AC-4: Inne joby Apilo (token, product sync) nadal mają limit prób
```gherkin
Given job typu apilo_token_keepalive lub apilo_product_sync
When job osiąga max_attempts
Then job jest oznaczany jako failed (zachowanie bez zmian)
```
## AC-5: Po udanym wysłaniu zamówienia czyszczone są powiązane failed joby
```gherkin
Given zamówienie wysłane pomyślnie do Apilo (apilo_order_id ustawiony)
When handler APILO_SEND_ORDER kończy się sukcesem
Then powiązane joby apilo_sync_payment i apilo_sync_status ze statusem failed
zostają usunięte lub anulowane (żeby nie zaśmiecały kolejki)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Infinite retry dla order-related Apilo jobów</name>
<files>autoload/Domain/CronJob/CronJobType.php, autoload/Domain/CronJob/CronJobRepository.php</files>
<action>
**CronJobType.php:**
1. Dodać stałą `APILO_ORDER_BACKOFF_SECONDS = 1800` (30 minut)
2. Dodać statyczną metodę `isOrderRelatedApiloJob($jobType)` zwracającą true dla:
- APILO_SEND_ORDER, APILO_SYNC_PAYMENT, APILO_SYNC_STATUS
**CronJobRepository::markFailed():**
3. Przed sprawdzeniem `$attempts >= $maxAttempts`:
- Pobrać `job_type` z bazy (dodać do selecta w linia 133)
- Jeśli `CronJobType::isOrderRelatedApiloJob($jobType)`:
- ZAWSZE wracaj do pending (nigdy failed)
- Użyj stałego backoffu `APILO_ORDER_BACKOFF_SECONDS` zamiast exponential
- Ustaw `last_error` jak normalnie
- Dla pozostałych jobów — logika bez zmian
UWAGA: Nie zmieniaj sygnatury markFailed() — dodaj job_type do wewnętrznego selecta
</action>
<verify>
1. Przeczytaj kod i zweryfikuj że:
- isOrderRelatedApiloJob zwraca true tylko dla 3 typów
- markFailed nigdy nie ustawia status=failed dla tych typów
- Inne joby zachowują się jak dotychczas
2. Uruchom: ./test.ps1 tests/Unit/Domain/CronJob/
</verify>
<done>AC-3, AC-4 satisfied: Order joby retry w nieskończoność, inne bez zmian</done>
</task>
<task type="auto">
<name>Task 2: Lepszy email + ostrzeżenie zamiast trwałego błędu + czyszczenie po sukcesie</name>
<files>cron.php</files>
<action>
**Email notification (linie ~763-781):**
1. Zmienić query o failed joby — RÓWNIEŻ szukać order-related jobów w statusie pending z dużą liczbą prób (np. attempts >= 10), żeby wysyłać ostrzeżenie
2. Dla każdego job-a: sparsować payload (json_decode jeśli string), wyciągnąć order_id
3. Jeśli order_id istnieje — pobrać z pp_shop_orders:
- `order_number` (lub `id` jeśli brak), `client_name`/`client_surname`, `date_order`, `total_brutto`
4. Sformatować email:
```
Job #X (apilo_send_order) — PONAWIANY CO 30 MIN
Zamówienie: #12345 (ID: 678)
Klient: Jan Kowalski
Data zamówienia: 2026-03-19 14:30:00
Kwota: 199.99 PLN
Próby: 15
Błąd: [last_error]
Ostatnia próba: [updated_at lub scheduled_at]
```
5. Dla jobów permanent failed (nie-order): zachować stary format "trwały błąd"
6. Temat: dodać numery zamówień jeśli dostępne
7. Email ma rozróżniać: "PONAWIANY" vs "TRWAŁY BŁĄD" w zależności od typu joba
**Czyszczenie po sukcesie (w handlerze APILO_SEND_ORDER, po linii ~522-524):**
8. Po pomyślnym wysłaniu zamówienia (`apilo_order_id` ustawiony):
- Usunąć/anulować failed/pending joby `apilo_sync_payment` i `apilo_sync_status`
z payload zawierającym ten sam order_id
- Użyć: `$mdb->delete('pp_cron_jobs', [...])` lub update status=cancelled
- To zapobiega zaśmiecaniu kolejki starymi retry jobami
UWAGA:
- Nazwy kolumn zamówienia: sprawdź jakie faktycznie są w pp_shop_orders (mogą być polskie)
- Payload w bazie to JSON string — json_decode($fj['payload'], true)
- Nie zmieniaj logiki wysyłania zamówień — tylko email i cleanup
</action>
<verify>
1. Przeczytaj zmodyfikowany kod
2. Zweryfikuj że query do pp_shop_orders używa poprawnych kolumn
3. Zweryfikuj brak błędów PHP (null handling, json_decode guard)
4. Uruchom: ./test.ps1
</verify>
<done>AC-1, AC-2, AC-5 satisfied: Email czytelny, cleanup po sukcesie</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logikę wysyłania zamówień do Apilo (curl, payload budowanie)
- Logikę exponential backoff dla NIE-order jobów
- Handlery APILO_SYNC_PAYMENT, APILO_SYNC_STATUS, APILO_STATUS_POLL (poza cleanup)
- Odbiorcę emaila i warunki wysyłki (poza rozszerzeniem query)
- Tabelę pp_shop_orders — żadnych nowych kolumn
## SCOPE LIMITS
- Tylko retry logic, email formatting, i cleanup
- Nie dodawać nowych tabel
- Nie zmieniać enqueue() ani fetchNext()
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Order-related Apilo joby nigdy nie dostają status=failed
- [ ] Backoff dla order jobów = stałe 30 min
- [ ] Inne joby zachowują stare zachowanie (exponential, max 10)
- [ ] Email zawiera numer zamówienia gdy dostępny
- [ ] Email rozróżnia "ponawiany" vs "trwały błąd"
- [ ] Po sukcesie wysyłki czyścimy related joby
- [ ] Brak błędów PHP
- [ ] Testy przechodzą (./test.ps1)
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Zamówienia Apilo są ponawiane w nieskończoność co 30 min
- Email notyfikacji zawiera czytelne dane zamówienia
- Po udanym wysłaniu czyszczone są stare joby
- Zero regresji w testach
</success_criteria>
<output>
After completion, create `.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,111 @@
---
phase: 09-apilo-email-fix
plan: 01
subsystem: integrations
tags: [apilo, cron, email, retry]
requires:
- phase: 08-apilo-orders-fix
provides: cron job system, Apilo email notification
provides:
- Infinite retry dla order-related Apilo jobów (30 min interval)
- Email notyfikacji z danymi zamówienia (numer, klient, kwota)
- Cleanup starych jobów po udanym wysłaniu
affects: []
tech-stack:
added: []
patterns:
- "isOrderRelatedApiloJob() — centralna identyfikacja order jobów Apilo"
- "Infinite retry pattern — stały backoff zamiast exponential dla krytycznych jobów"
key-files:
modified:
- autoload/Domain/CronJob/CronJobType.php
- autoload/Domain/CronJob/CronJobRepository.php
- cron.php
key-decisions:
- "Order joby Apilo nigdy nie failują trwale — infinite retry co 30 min"
- "Email rozróżnia PONAWIANY vs TRWAŁY BŁĄD"
- "Po udanym wysłaniu zamówienia czyszczone są stuck joby sync_payment/sync_status"
duration: ~15min
completed: 2026-03-19
---
# Phase 9 Plan 01: Apilo email fix + infinite retry — Summary
**Email notyfikacji Apilo wzbogacony o dane zamówienia (numer, klient, kwota) + order joby ponawiane w nieskończoność co 30 min zamiast permanent failure po 10 próbach.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Completed | 2026-03-19 |
| Tasks | 2 completed |
| Files modified | 4 (+ 1 test file) |
| Tests | 820 passed, 2277 assertions |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Email zawiera dane zamówienia | Pass | Numer, klient, data, kwota z pp_shop_orders |
| AC-2: Brak order_id nie powoduje błędu | Pass | Graceful handling — pokazuje tylko dane joba |
| AC-3: Order joby retry co 30 min w nieskończoność | Pass | isOrderRelatedApiloJob() + stały backoff 1800s |
| AC-4: Inne joby zachowują limit prób | Pass | Testy potwierdzają — price_history nadal failuje po max_attempts |
| AC-5: Cleanup po udanym wysłaniu | Pass | delete stuck sync_payment/sync_status jobów |
## Accomplishments
- Order-related Apilo joby (send_order, sync_payment, sync_status) nigdy nie wpadają w permanent failure — zawsze wracają do pending co 30 min
- Email notyfikacji zawiera czytelne dane zamówienia zamiast surowego JSON payload
- Temat emaila zawiera numery zamówień dla szybkiej identyfikacji
- Email rozróżnia "PONAWIANY CO 30 MIN" vs "TRWAŁY BŁĄD" w zależności od typu joba
- Po udanym wysłaniu zamówienia do Apilo czyszczone są stare stuck joby sync_payment/sync_status
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/Domain/CronJob/CronJobType.php` | Modified | +APILO_ORDER_BACKOFF_SECONDS (1800s), +isOrderRelatedApiloJob() |
| `autoload/Domain/CronJob/CronJobRepository.php` | Modified | markFailed() — infinite retry dla order jobów |
| `cron.php` | Modified | Email z danymi zamówienia + cleanup po sukcesie |
| `tests/Unit/Domain/CronJob/CronJobRepositoryTest.php` | Modified | +2 testy infinite retry, fix mocków z job_type |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Stały backoff 1800s zamiast exponential | Zamówienia muszą trafić do Apilo — przewidywalny interwał ważniejszy niż agresywny retry | Order joby ponawiane regularnie co 30 min |
| Email ostrzegawczy zamiast "trwały błąd" | Order joby nigdy nie failują trwale, ale admin musi wiedzieć o problemie | Zmieniony temat i treść emaila |
| Cleanup starych jobów po sukcesie | Zapobieganie zaśmiecaniu kolejki stuck jobami sync_payment/sync_status | Delete zamiast cancel — prostsze |
## Deviations from Plan
None — plan executed as written.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Testy CronJob failowały — mock get() nie zwracał job_type | Dodano job_type do willReturn() w 3 istniejących testach |
| test.ps1 nie istnieje | Użyto bezpośrednio `php phpunit.phar` |
## Next Phase Readiness
**Ready:**
- System retry Apilo jest kompletny i odporny na awarie
- Email notyfikacji daje adminowi pełen kontekst do szybkiej reakcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 09-apilo-email-fix, Plan: 01*
*Completed: 2026-03-19*

File diff suppressed because one or more lines are too long

View File

@@ -55,7 +55,7 @@ composer test # standard
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
Current suite: **818 tests, 2275 assertions**.
Current suite: **820 tests, 2277 assertions**.
### Creating Updates
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. ZIP structure must start directly from project directories — no version subfolder inside the archive.

View File

@@ -130,10 +130,22 @@ class CronJobRepository
*/
public function markFailed($jobId, $error, $attempt = 1)
{
$job = $this->db->get('pp_cron_jobs', ['max_attempts', 'attempts'], ['id' => $jobId]);
$job = $this->db->get('pp_cron_jobs', ['job_type', 'max_attempts', 'attempts'], ['id' => $jobId]);
$attempts = $job ? (int) $job['attempts'] : $attempt;
$maxAttempts = $job ? (int) $job['max_attempts'] : 10;
$jobType = $job ? $job['job_type'] : '';
// Order-related Apilo joby — infinite retry co 30 min
if (CronJobType::isOrderRelatedApiloJob($jobType)) {
$nextRun = date('Y-m-d H:i:s', time() + CronJobType::APILO_ORDER_BACKOFF_SECONDS);
$this->db->update('pp_cron_jobs', [
'status' => CronJobType::STATUS_PENDING,
'last_error' => mb_substr($error, 0, 500),
'scheduled_at' => $nextRun,
], ['id' => $jobId]);
return;
}
if ($attempts >= $maxAttempts) {
// Przekroczono limit prób — trwale failed

View File

@@ -34,6 +34,7 @@ class CronJobType
// Backoff
const BASE_BACKOFF_SECONDS = 60;
const MAX_BACKOFF_SECONDS = 3600;
const APILO_ORDER_BACKOFF_SECONDS = 1800; // 30 min — stały interwał dla order jobów
/**
* @return string[]
@@ -69,6 +70,19 @@ class CronJobType
];
}
/**
* @param string $jobType
* @return bool
*/
public static function isOrderRelatedApiloJob($jobType)
{
return in_array($jobType, [
self::APILO_SEND_ORDER,
self::APILO_SYNC_PAYMENT,
self::APILO_SYNC_STATUS,
], true);
}
/**
* @param int $attempt
* @return int

View File

@@ -19,4 +19,9 @@ $config['trustmate']['enabled'] = true;
$config['trustmate']['uid'] = '34eb36ba-c715-4cdc-8707-22376c9f14c7';
$config['cron_key'] = 'Gi7FzWtkry19hZ1BqT1LKEWfwokQpigh';
$database_inst['host_remote'] = 'host700513.hostido.net.pl';
$database_inst['user'] = 'host700513_pomysloweprezenty-pl';
$database_inst['password'] = 'QBVbveHAzR78UN8pc7Um';
$database_inst['name'] = 'host700513_pomysloweprezenty-pl';
?>

View File

@@ -521,6 +521,17 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, func
{
$mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] );
\Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie wysłane do Apilo (apilo_order_id: ' . $response['id'] . ')', [ 'http_code' => $http_code_send, 'response' => $response ] );
// Wyczyść stare stuck joby sync_payment/sync_status dla tego zamówienia
$orderPayloadJson = json_encode(['order_id' => (int)$order['id']]);
$mdb->delete('pp_cron_jobs', [
'AND' => [
'job_type' => [\Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, \Domain\CronJob\CronJobType::APILO_SYNC_STATUS],
'payload' => $orderPayloadJson,
'status' => [\Domain\CronJob\CronJobType::STATUS_PENDING, \Domain\CronJob\CronJobType::STATUS_FAILED],
]
]);
echo '<p>Wysłałem zamówienie do apilo.com: ID: ' . $order['id'] . ' - ' . $response['id'] . '</p>';
}
}
@@ -760,7 +771,8 @@ $processor->registerHandler( \Domain\CronJob\CronJobType::TRUSTMATE_INVITATION,
$result = $processor->run( 20 );
// Powiadomienie mailowe o trwale nieudanych zadaniach Apilo
// Powiadomienie mailowe o problemach Apilo
// 1. Trwale failed joby (nie-order: token, product sync itp.)
$failedApiloJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'completed_at'], [
'AND' => [
'status' => 'failed',
@@ -768,16 +780,51 @@ $failedApiloJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error',
'completed_at[>=]' => date('Y-m-d H:i:s', time() - 120),
]
]);
if (!empty($failedApiloJobs)) {
$emailBody = "Następujące zadania Apilo zakończyły się trwałym błędem (wyczerpano limit prób):\n\n";
foreach ($failedApiloJobs as $fj) {
$emailBody .= "Job #" . $fj['id'] . " (" . $fj['job_type'] . ")\n";
$emailBody .= " Payload: " . $fj['payload'] . "\n";
// 2. Order joby z wieloma próbami (infinite retry, ale wymagają uwagi)
$stuckOrderJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'scheduled_at'], [
'AND' => [
'status' => 'pending',
'job_type' => [\Domain\CronJob\CronJobType::APILO_SEND_ORDER, \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, \Domain\CronJob\CronJobType::APILO_SYNC_STATUS],
'attempts[>=]' => 10,
]
]);
$allProblems = array_merge($failedApiloJobs, $stuckOrderJobs);
if (!empty($allProblems)) {
$emailBody = "";
$orderNumbers = [];
foreach ($allProblems as $fj) {
$payloadData = is_string($fj['payload']) ? json_decode($fj['payload'], true) : $fj['payload'];
$orderId = isset($payloadData['order_id']) ? (int)$payloadData['order_id'] : 0;
$isOrderJob = \Domain\CronJob\CronJobType::isOrderRelatedApiloJob($fj['job_type']);
$statusLabel = $isOrderJob ? 'PONAWIANY CO 30 MIN' : 'TRWAŁY BŁĄD';
$emailBody .= "Job #" . $fj['id'] . " (" . $fj['job_type'] . ") — " . $statusLabel . "\n";
if ($orderId > 0) {
$order = $mdb->get('pp_shop_orders', ['id', 'client_name', 'client_surname', 'date_order', 'summary'], ['id' => $orderId]);
if ($order) {
$emailBody .= " Zamówienie: #" . $order['id'] . "\n";
$emailBody .= " Klient: " . trim($order['client_name'] . ' ' . $order['client_surname']) . "\n";
$emailBody .= " Data zamówienia: " . $order['date_order'] . "\n";
$emailBody .= " Kwota: " . $order['summary'] . " PLN\n";
$orderNumbers[] = '#' . $order['id'];
}
}
$emailBody .= " Próby: " . $fj['attempts'] . "\n";
$emailBody .= " Błąd: " . $fj['last_error'] . "\n";
$emailBody .= " Data: " . $fj['completed_at'] . "\n\n";
$emailBody .= " Data: " . ($fj['completed_at'] ? $fj['completed_at'] : $fj['scheduled_at']) . "\n\n";
}
\Shared\Helpers\Helpers::send_email('biuro@project-pro.pl', 'shopPRO: Trwały błąd synchronizacji Apilo (' . count($failedApiloJobs) . ' zadań)', $emailBody);
$subject = 'shopPRO: Problemy synchronizacji Apilo';
if (!empty($orderNumbers)) {
$subject .= ' — zamówienia ' . implode(', ', array_unique($orderNumbers));
}
$subject .= ' (' . count($allProblems) . ' zadań)';
\Shared\Helpers\Helpers::send_email('biuro@project-pro.pl', $subject, $emailBody);
}
echo '<hr>';

View File

@@ -4,6 +4,17 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
## ver. 0.342 (2026-03-19) - Apilo: email z danymi zamówienia + infinite retry dla order jobów
- **FIX**: `cron.php` — email notyfikacji Apilo zawiera teraz dane zamówienia (numer, klient, data, kwota) zamiast surowego JSON payload; temat emaila zawiera numery zamówień
- **NEW**: `autoload/Domain/CronJob/CronJobType.php``isOrderRelatedApiloJob()` identyfikuje order joby (send_order, sync_payment, sync_status)
- **NEW**: `autoload/Domain/CronJob/CronJobRepository.php` — order-related Apilo joby ponawiane w nieskończoność co 30 min zamiast permanent failure po 10 próbach
- **NEW**: `cron.php` — email rozróżnia "PONAWIANY CO 30 MIN" (order joby) vs "TRWAŁY BŁĄD" (inne joby)
- **NEW**: `cron.php` — po udanym wysłaniu zamówienia do Apilo czyszczone są stuck joby sync_payment/sync_status
- **TEST**: +2 testy infinite retry w `CronJobRepositoryTest`
---
## ver. 0.341 (2026-03-16) - Bugfix: zamówienia nie wysyłały się do Apilo + retry i powiadomienia
- **FIX**: `cron.php` — dodano brakujące `$apiloRepository` do klauzul `use()` w 5 handlerach cron (APILO_TOKEN_KEEPALIVE, APILO_SEND_ORDER, APILO_PRODUCT_SYNC, APILO_PRICELIST_SYNC, APILO_STATUS_POLL); regresja z ver. 0.339 (split IntegrationsRepository → ApiloRepository) powodowała `Call to a member function apiloGetAccessToken() on null`

View File

@@ -23,10 +23,10 @@ composer test # standard
## Aktualny stan
```text
OK (817 tests, 2271 assertions)
OK (820 tests, 2277 assertions)
```
Zweryfikowano: 2026-03-12 (ver. 0.337)
Zweryfikowano: 2026-03-19 (ver. 0.342)
## Konfiguracja

81
temp/diagnose_apilo.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
/**
* Diagnostic script for Apilo integration on the instance DB
* Temporary — delete after diagnosis
*/
$db_host = 'host700513.hostido.net.pl';
$db_user = 'host700513_pomysloweprezenty-pl';
$db_pass = 'QBVbveHAzR78UN8pc7Um';
$db_name = 'host700513_pomysloweprezenty-pl';
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== 1. APILO SETTINGS ===\n";
$stmt = $pdo->query("SELECT name, LEFT(value, 200) as value FROM pp_shop_apilo_settings ORDER BY name");
foreach ($stmt as $row) {
echo sprintf(" %-35s = %s\n", $row['name'], $row['value']);
}
echo "\n=== 2. PENDING ORDERS (apilo_order_id IS NULL) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id IS NULL");
$row = $stmt->fetch();
echo " Total with NULL: " . $row['cnt'] . "\n";
$stmt = $pdo->query("SELECT id, date_order, status, apilo_order_id FROM pp_shop_orders WHERE apilo_order_id IS NULL ORDER BY date_order DESC LIMIT 10");
echo " Last 10 NULL orders:\n";
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== 3. FAILED ORDERS (apilo_order_id = -1) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id = -1");
$row = $stmt->fetch();
echo " Total with -1: " . $row['cnt'] . "\n";
$stmt = $pdo->query("SELECT id, date_order, status FROM pp_shop_orders WHERE apilo_order_id = -1 ORDER BY date_order DESC LIMIT 10");
echo " Last 10 failed orders:\n";
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== 4. SKIPPED ORDERS (apilo_order_id = -2) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id = -2");
$row = $stmt->fetch();
echo " Total with -2: " . $row['cnt'] . "\n";
echo "\n=== 5. RECENT APILO LOGS (pp_log) ===\n";
$stmt = $pdo->query("SELECT id, action, order_id, message, date FROM pp_log WHERE action LIKE '%apilo%' OR action LIKE '%send_order%' OR action LIKE '%token%' OR action LIKE '%keepalive%' ORDER BY id DESC LIMIT 20");
$rows = $stmt->fetchAll();
if (empty($rows)) {
echo " No Apilo logs found in pp_log\n";
// Try broader search
$stmt = $pdo->query("SELECT id, action, order_id, message, date FROM pp_log ORDER BY id DESC LIMIT 10");
$rows = $stmt->fetchAll();
echo " Last 10 logs (any type):\n";
}
foreach ($rows as $row) {
echo sprintf(" [%s] #%s action=%s order=%s msg=%s\n", $row['date'], $row['id'], $row['action'], $row['order_id'], substr($row['message'], 0, 120));
}
echo "\n=== 6. CRON JOBS STATUS ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs GROUP BY job_type, status ORDER BY job_type, status");
echo " Job type / status / count:\n";
foreach ($stmt as $row) {
echo sprintf(" %-30s %-12s %s\n", $row['job_type'], $row['status'], $row['cnt']);
}
echo "\n=== 7. RECENT CRON EXECUTIONS ===\n";
$stmt = $pdo->query("SELECT id, job_type, status, created_at, processed_at FROM pp_cron_jobs ORDER BY id DESC LIMIT 15");
foreach ($stmt as $row) {
echo sprintf(" #%s type=%s status=%s created=%s processed=%s\n", $row['id'], $row['job_type'], $row['status'], $row['created_at'], $row['processed_at']);
}
echo "\n=== 8. SUCCESSFULLY SENT ORDERS (last 5) ===\n";
$stmt = $pdo->query("SELECT id, date_order, apilo_order_id, status FROM pp_shop_orders WHERE apilo_order_id > 0 ORDER BY date_order DESC LIMIT 5");
foreach ($stmt as $row) {
echo sprintf(" #%s date=%s apilo_id=%s status=%s\n", $row['id'], $row['date_order'], $row['apilo_order_id'], $row['status']);
}
echo "\nDone.\n";

49
temp/diagnose_apilo2.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
$pdo = new PDO("mysql:host=host700513.hostido.net.pl;dbname=host700513_pomysloweprezenty-pl;charset=utf8", 'host700513_pomysloweprezenty-pl', 'QBVbveHAzR78UN8pc7Um');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== CRON JOBS TABLE COLUMNS ===\n";
$stmt = $pdo->query("DESCRIBE pp_cron_jobs");
foreach ($stmt as $row) echo " " . $row['Field'] . " (" . $row['Type'] . ")\n";
echo "\n=== RECENT CRON JOBS (last 20) ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs ORDER BY id DESC LIMIT 20");
foreach ($stmt as $row) {
echo " #" . $row['id'] . " type=" . $row['job_type'] . " status=" . $row['status'] . " created=" . ($row['created_at'] ?? $row['date'] ?? 'n/a') . "\n";
}
echo "\n=== PENDING CRON JOBS ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs WHERE status = 'pending' ORDER BY id");
foreach ($stmt as $row) {
echo " #" . $row['id'] . " type=" . $row['job_type'];
foreach ($row as $k => $v) if ($v !== null && $k !== 'id' && $k !== 'job_type') echo " $k=$v";
echo "\n";
}
echo "\n=== NULL ORDERS SINCE 2026-03-15 (after last successful sync) ===\n";
$stmt = $pdo->query("SELECT id, date_order, status FROM pp_shop_orders WHERE apilo_order_id IS NULL AND date_order >= '2026-03-15 14:00:00' ORDER BY date_order ASC");
$rows = $stmt->fetchAll();
echo " Count: " . count($rows) . "\n";
foreach ($rows as $row) {
echo sprintf(" #%s date=%s status=%s\n", $row['id'], $row['date_order'], $row['status']);
}
echo "\n=== NULL ORDERS COUNT BY DATE RANGE ===\n";
$stmt = $pdo->query("SELECT
SUM(CASE WHEN date_order >= '2026-03-15 14:00:00' THEN 1 ELSE 0 END) as since_break,
SUM(CASE WHEN date_order >= '2024-08-20' AND date_order < '2026-03-15 14:00:00' THEN 1 ELSE 0 END) as before_break_after_sync_start,
SUM(CASE WHEN date_order < '2024-08-20' THEN 1 ELSE 0 END) as before_sync_start
FROM pp_shop_orders WHERE apilo_order_id IS NULL");
$row = $stmt->fetch();
echo " Since break (2026-03-15 14:00+): " . $row['since_break'] . "\n";
echo " Before break, after sync_start: " . $row['before_break_after_sync_start'] . "\n";
echo " Before sync_start (2024-08-20): " . $row['before_sync_start'] . "\n";
echo "\n=== LAST SUCCESSFUL SEND ORDER JOB ===\n";
$stmt = $pdo->query("SELECT * FROM pp_cron_jobs WHERE job_type = 'apilo_send_order' AND status = 'completed' ORDER BY id DESC LIMIT 3");
foreach ($stmt as $row) {
foreach ($row as $k => $v) if ($v !== null) echo " $k=$v";
echo "\n";
}
echo "\nDone.\n";

77
temp/fix_apilo_queue.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
/**
* Fix stuck Apilo cron jobs on the instance after deploying the closure fix.
* Run once after uploading the fixed cron.php.
*
* Temporary — delete after use.
*/
$pdo = new PDO("mysql:host=host700513.hostido.net.pl;dbname=host700513_pomysloweprezenty-pl;charset=utf8", 'host700513_pomysloweprezenty-pl', 'QBVbveHAzR78UN8pc7Um');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== BEFORE FIX ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs WHERE status IN ('pending','processing','failed') GROUP BY job_type, status ORDER BY job_type");
foreach ($stmt as $row) {
echo " {$row['job_type']} {$row['status']} {$row['cnt']}\n";
}
// 1. Reset the stuck apilo_send_order job (attempts back to 0, clear error)
echo "\n=== Resetting apilo_send_order pending jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, scheduled_at = NOW() WHERE job_type = 'apilo_send_order' AND status = 'pending'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} apilo_send_order jobs\n";
// 2. Reset token keepalive failed jobs
echo "\n=== Resetting apilo_token_keepalive failed jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, status = 'pending', scheduled_at = NOW() WHERE job_type = 'apilo_token_keepalive' AND status = 'failed'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} token keepalive jobs\n";
// 3. Cancel sync_payment and sync_status jobs for orders that haven't been sent yet
// (these orders have apilo_order_id = NULL so sync is pointless — they'll be re-queued after order is sent)
echo "\n=== Cancelling premature sync jobs for unsent orders ===\n";
$stmt = $pdo->query("
SELECT cj.id, cj.job_type, cj.payload
FROM pp_cron_jobs cj
WHERE cj.status = 'pending'
AND cj.job_type IN ('apilo_sync_payment', 'apilo_sync_status')
");
$to_cancel = [];
foreach ($stmt as $row) {
$payload = json_decode($row['payload'], true);
$order_id = $payload['order_id'] ?? 0;
// Check if this order has been sent to Apilo
$check = $pdo->prepare("SELECT apilo_order_id FROM pp_shop_orders WHERE id = ?");
$check->execute([$order_id]);
$order = $check->fetch();
if ($order && ($order['apilo_order_id'] === null || (int)$order['apilo_order_id'] <= 0)) {
$to_cancel[] = $row['id'];
}
}
if ($to_cancel) {
$ids = implode(',', $to_cancel);
$pdo->exec("UPDATE pp_cron_jobs SET status = 'cancelled' WHERE id IN ($ids)");
echo " Cancelled " . count($to_cancel) . " premature sync jobs\n";
} else {
echo " No premature sync jobs to cancel\n";
}
// 4. Reset failed product_sync, pricelist_sync, status_poll jobs
echo "\n=== Resetting other failed Apilo jobs ===\n";
$stmt = $pdo->prepare("UPDATE pp_cron_jobs SET attempts = 0, last_error = NULL, scheduled_at = NOW() WHERE job_type IN ('apilo_product_sync', 'apilo_pricelist_sync', 'apilo_status_poll') AND status = 'pending'");
$stmt->execute();
echo " Reset {$stmt->rowCount()} other Apilo pending jobs\n";
echo "\n=== AFTER FIX ===\n";
$stmt = $pdo->query("SELECT job_type, status, COUNT(*) as cnt FROM pp_cron_jobs WHERE status IN ('pending','processing','failed') GROUP BY job_type, status ORDER BY job_type");
foreach ($stmt as $row) {
echo " {$row['job_type']} {$row['status']} {$row['cnt']}\n";
}
echo "\n=== ORDERS NEEDING SEND (apilo_order_id IS NULL, after sync_start) ===\n";
$stmt = $pdo->query("SELECT COUNT(*) as cnt FROM pp_shop_orders WHERE apilo_order_id IS NULL AND date_order >= '2024-08-20'");
$row = $stmt->fetch();
echo " " . $row['cnt'] . " orders to send\n";
echo " (cron processes 1 per run, should catch up within " . $row['cnt'] . " cron cycles)\n";
echo "\nDone. Deploy fixed cron.php to instance, then run this script.\n";

View File

@@ -0,0 +1,70 @@
<?php
/**
* Rebuild changelog-data.html from docs/CHANGELOG.md
* One-time script — delete after use.
*/
$md = file_get_contents(__DIR__ . '/../docs/CHANGELOG.md');
$lines = explode("\n", $md);
$entries = [];
$currentVersion = null;
$currentDate = null;
$currentLines = [];
foreach ($lines as $line) {
// Match: ## ver. 0.341 (2026-03-16) - Description here
if (preg_match('/^## ver\. ([\d.]+) \((\d{4}-\d{2}-\d{2})\) - (.+)$/', $line, $m)) {
// Save previous entry
if ($currentVersion !== null) {
$entries[] = [
'version' => $currentVersion,
'date' => $currentDate,
'lines' => $currentLines,
];
}
$currentVersion = $m[1];
$currentDate = $m[2];
$currentLines = [];
} elseif ($currentVersion !== null && trim($line) !== '' && trim($line) !== '---') {
// Clean markdown formatting for HTML
$clean = $line;
$clean = preg_replace('/^\- \*\*([A-Z]+)\*\*: /', '$1 - ', $clean);
$clean = preg_replace('/`([^`]+)`/', '$1', $clean);
$clean = str_replace(['**', '__'], '', $clean);
$clean = trim($clean);
if ($clean) {
$currentLines[] = $clean;
}
}
}
// Last entry
if ($currentVersion !== null) {
$entries[] = [
'version' => $currentVersion,
'date' => $currentDate,
'lines' => $currentLines,
];
}
// Build HTML
$html = '';
foreach ($entries as $entry) {
$dateParts = explode('-', $entry['date']);
$dateFormatted = $dateParts[2] . '.' . $dateParts[1] . '.' . $dateParts[0];
$desc = implode("\n", $entry['lines']);
$desc = htmlspecialchars($desc, ENT_QUOTES, 'UTF-8');
$html .= '<b>ver. ' . $entry['version'] . ' - ' . $dateFormatted . '</b><br />' . "\n";
$html .= $desc . "\n";
$html .= '<hr>' . "\n";
}
$outPath = __DIR__ . '/../updates/changelog-data.html';
file_put_contents($outPath, $html);
$size = filesize($outPath);
echo "Generated " . count($entries) . " entries\n";
echo "File size: " . number_format($size) . " bytes\n";
echo "Output: $outPath\n";

View File

@@ -205,6 +205,7 @@ class CronJobRepositoryTest extends TestCase
{
// Job with attempts < max_attempts → reschedule with backoff
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 10,
'attempts' => 2,
]);
@@ -228,6 +229,7 @@ class CronJobRepositoryTest extends TestCase
{
// Job with attempts >= max_attempts → permanent failure
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 3,
'attempts' => 3,
]);
@@ -249,6 +251,7 @@ class CronJobRepositoryTest extends TestCase
public function testMarkFailedTruncatesErrorTo500Chars(): void
{
$this->mockDb->method('get')->willReturn([
'job_type' => 'price_history',
'max_attempts' => 10,
'attempts' => 1,
]);
@@ -268,6 +271,51 @@ class CronJobRepositoryTest extends TestCase
$this->repo->markFailed(1, $longError);
}
public function testMarkFailedApiloOrderJobNeverPermanentlyFails(): void
{
// apilo_send_order with max attempts reached → still pending (infinite retry)
$this->mockDb->method('get')->willReturn([
'job_type' => 'apilo_send_order',
'max_attempts' => 10,
'attempts' => 15,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending'
&& isset($data['scheduled_at'])
&& isset($data['last_error']);
}),
['id' => 5]
);
$this->repo->markFailed(5, 'API timeout');
}
public function testMarkFailedApiloSyncPaymentInfiniteRetry(): void
{
$this->mockDb->method('get')->willReturn([
'job_type' => 'apilo_sync_payment',
'max_attempts' => 10,
'attempts' => 10,
]);
$this->mockDb->expects($this->once())
->method('update')
->with(
'pp_cron_jobs',
$this->callback(function ($data) {
return $data['status'] === 'pending';
}),
['id' => 6]
);
$this->repo->markFailed(6, 'Apilo unavailable');
}
// --- hasPendingJob ---
public function testHasPendingJobReturnsTrueWhenExists(): void

View File

@@ -1,4 +1,7 @@
<b>ver. 0.341 - 16.03.2026</b><br />
Bugfix: naprawiono wysyłkę zamówień do Apilo (regresja z 0.339), retry failed orders co 1h, powiadomienia mailowe o błędach
<hr>
<b>ver. 0.341 - 16.03.2026</b><br />
FIX - cron.php — dodano brakujące $apiloRepository do klauzul use() w 5 handlerach cron (APILO_TOKEN_KEEPALIVE, APILO_SEND_ORDER, APILO_PRODUCT_SYNC, APILO_PRICELIST_SYNC, APILO_STATUS_POLL); regresja z ver. 0.339 (split IntegrationsRepository → ApiloRepository) powodowała Call to a member function apiloGetAccessToken() on null
FIX - cron.php — zamówienia z apilo_order_id = -1 (failed) są teraz automatycznie ponawiane co 1h zamiast trwale pomijane; priorytet: najpierw nowe zamówienia (NULL), potem retry (-1)
NEW - cron.php — powiadomienie mailowe na biuro@project-pro.pl przy błędzie cURL wysyłania zamówienia do Apilo