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>
This commit is contained in:
@@ -35,6 +35,7 @@ Status: Planning
|
|||||||
|-------|------|-------|--------|-----------|
|
|-------|------|-------|--------|-----------|
|
||||||
| 7 | Coupon Fatal Error — order placement crash | 1 | Done | 2026-03-15 |
|
| 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 |
|
| 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
|
## Phase Details
|
||||||
|
|
||||||
@@ -66,4 +67,4 @@ Status: Planning
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-03-12*
|
*Roadmap created: 2026-03-12*
|
||||||
*Last updated: 2026-03-16*
|
*Last updated: 2026-03-19*
|
||||||
|
|||||||
@@ -5,25 +5,25 @@
|
|||||||
See: .paul/PROJECT.md (updated 2026-03-12)
|
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.
|
**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
|
## Current Position
|
||||||
|
|
||||||
Milestone: Hotfix — Apilo orders not sending
|
Milestone: Hotfix
|
||||||
Phase: 8 — Diagnoza i naprawa wysyłki zamówień do Apilo — Complete
|
Phase: 9 — Apilo email notification + infinite retry — Complete
|
||||||
Plan: 08-01 complete (phase done)
|
Plan: 09-01 complete (phase done)
|
||||||
Status: UNIFY complete, phase 8 finished
|
Status: UNIFY complete, phase 9 finished
|
||||||
Last activity: 2026-03-16 — 08-01 UNIFY complete
|
Last activity: 2026-03-19 — 09-01 UNIFY complete
|
||||||
|
|
||||||
Progress:
|
Progress:
|
||||||
- Phase 8: [██████████] 100% (COMPLETE)
|
- Phase 9: [██████████] 100% (COMPLETE)
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
Current loop state (phase 8, plan 01):
|
Current loop state (phase 9, plan 01):
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
✓ ✓ ✓ [Phase 8 complete]
|
✓ ✓ ✓ [Phase 9 complete]
|
||||||
```
|
```
|
||||||
|
|
||||||
Previous phases:
|
Previous phases:
|
||||||
@@ -33,6 +33,7 @@ Phase 5: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-0
|
|||||||
Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
|
Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-12]
|
||||||
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
|
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
|
||||||
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
|
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
|
||||||
|
Phase 9: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Accumulated Context
|
## 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: 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: Retry -1 orders co 1h zamiast permanent failure
|
||||||
- 2026-03-16: Email notification o trwale failed Apilo jobach
|
- 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
|
### Deferred Issues
|
||||||
None.
|
None.
|
||||||
@@ -51,10 +55,10 @@ None.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16
|
Last session: 2026-03-19
|
||||||
Stopped at: Phase 08 UNIFY complete — Apilo fix loop closed
|
Stopped at: Phase 09 UNIFY complete
|
||||||
Next action: Deploy fix to instance, then /paul:progress for next work
|
Next action: Deploy fix or /paul:progress for next work
|
||||||
Resume file: .paul/phases/08-apilo-orders-fix/08-01-SUMMARY.md
|
Resume file: .paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
|
||||||
|
|
||||||
---
|
---
|
||||||
*STATE.md — Updated after every significant action*
|
*STATE.md — Updated after every significant action*
|
||||||
|
|||||||
201
.paul/phases/09-apilo-email-fix/09-01-PLAN.md
Normal file
201
.paul/phases/09-apilo-email-fix/09-01-PLAN.md
Normal 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>
|
||||||
111
.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
Normal file
111
.paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
Normal 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
@@ -55,7 +55,7 @@ composer test # standard
|
|||||||
|
|
||||||
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -130,10 +130,22 @@ class CronJobRepository
|
|||||||
*/
|
*/
|
||||||
public function markFailed($jobId, $error, $attempt = 1)
|
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;
|
$attempts = $job ? (int) $job['attempts'] : $attempt;
|
||||||
$maxAttempts = $job ? (int) $job['max_attempts'] : 10;
|
$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) {
|
if ($attempts >= $maxAttempts) {
|
||||||
// Przekroczono limit prób — trwale failed
|
// Przekroczono limit prób — trwale failed
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class CronJobType
|
|||||||
// Backoff
|
// Backoff
|
||||||
const BASE_BACKOFF_SECONDS = 60;
|
const BASE_BACKOFF_SECONDS = 60;
|
||||||
const MAX_BACKOFF_SECONDS = 3600;
|
const MAX_BACKOFF_SECONDS = 3600;
|
||||||
|
const APILO_ORDER_BACKOFF_SECONDS = 1800; // 30 min — stały interwał dla order jobów
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return string[]
|
* @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
|
* @param int $attempt
|
||||||
* @return int
|
* @return int
|
||||||
|
|||||||
63
cron.php
63
cron.php
@@ -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'] ] );
|
$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 ] );
|
\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>';
|
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 );
|
$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'], [
|
$failedApiloJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'completed_at'], [
|
||||||
'AND' => [
|
'AND' => [
|
||||||
'status' => 'failed',
|
'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),
|
'completed_at[>=]' => date('Y-m-d H:i:s', time() - 120),
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
if (!empty($failedApiloJobs)) {
|
// 2. Order joby z wieloma próbami (infinite retry, ale wymagają uwagi)
|
||||||
$emailBody = "Następujące zadania Apilo zakończyły się trwałym błędem (wyczerpano limit prób):\n\n";
|
$stuckOrderJobs = $mdb->select('pp_cron_jobs', ['id', 'job_type', 'last_error', 'payload', 'attempts', 'scheduled_at'], [
|
||||||
foreach ($failedApiloJobs as $fj) {
|
'AND' => [
|
||||||
$emailBody .= "Job #" . $fj['id'] . " (" . $fj['job_type'] . ")\n";
|
'status' => 'pending',
|
||||||
$emailBody .= " Payload: " . $fj['payload'] . "\n";
|
'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 .= " Próby: " . $fj['attempts'] . "\n";
|
||||||
$emailBody .= " Błąd: " . $fj['last_error'] . "\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>';
|
echo '<hr>';
|
||||||
|
|||||||
@@ -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
|
## 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`
|
- **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`
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ composer test # standard
|
|||||||
## Aktualny stan
|
## Aktualny stan
|
||||||
|
|
||||||
```text
|
```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
|
## Konfiguracja
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ class CronJobRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
// Job with attempts < max_attempts → reschedule with backoff
|
// Job with attempts < max_attempts → reschedule with backoff
|
||||||
$this->mockDb->method('get')->willReturn([
|
$this->mockDb->method('get')->willReturn([
|
||||||
|
'job_type' => 'price_history',
|
||||||
'max_attempts' => 10,
|
'max_attempts' => 10,
|
||||||
'attempts' => 2,
|
'attempts' => 2,
|
||||||
]);
|
]);
|
||||||
@@ -228,6 +229,7 @@ class CronJobRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
// Job with attempts >= max_attempts → permanent failure
|
// Job with attempts >= max_attempts → permanent failure
|
||||||
$this->mockDb->method('get')->willReturn([
|
$this->mockDb->method('get')->willReturn([
|
||||||
|
'job_type' => 'price_history',
|
||||||
'max_attempts' => 3,
|
'max_attempts' => 3,
|
||||||
'attempts' => 3,
|
'attempts' => 3,
|
||||||
]);
|
]);
|
||||||
@@ -249,6 +251,7 @@ class CronJobRepositoryTest extends TestCase
|
|||||||
public function testMarkFailedTruncatesErrorTo500Chars(): void
|
public function testMarkFailedTruncatesErrorTo500Chars(): void
|
||||||
{
|
{
|
||||||
$this->mockDb->method('get')->willReturn([
|
$this->mockDb->method('get')->willReturn([
|
||||||
|
'job_type' => 'price_history',
|
||||||
'max_attempts' => 10,
|
'max_attempts' => 10,
|
||||||
'attempts' => 1,
|
'attempts' => 1,
|
||||||
]);
|
]);
|
||||||
@@ -268,6 +271,51 @@ class CronJobRepositoryTest extends TestCase
|
|||||||
$this->repo->markFailed(1, $longError);
|
$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 ---
|
// --- hasPendingJob ---
|
||||||
|
|
||||||
public function testHasPendingJobReturnsTrueWhenExists(): void
|
public function testHasPendingJobReturnsTrueWhenExists(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user