feat(135): accounting net correctness
Phase 135 complete: - Store VAT-aware total_net for new receipts - Use source net or item-level VAT fallback for daily statistics - Document no-backfill boundary and tooling gaps
This commit is contained in:
@@ -13,8 +13,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 3.9.0-dev |
|
||||
| Status | v3.9 Stabilizacja i splata dlugu technicznego in progress - Phase 134 backlog reality check complete; Phase 135 ready to plan |
|
||||
| Last Updated | 2026-05-16 (Phase 134 closed) |
|
||||
| Status | v3.9 Stabilizacja i splata dlugu technicznego in progress - Phase 135 accounting net correctness complete; Phase 136 ready to plan |
|
||||
| Last Updated | 2026-05-16 (Phase 135 closed) |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -134,6 +134,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
- [x] Hardening Erli: spojna diagnostyka importu/ACK w `integration_order_sync_state.last_error`, brak ACK po blednym batchu, testy jednostkowe import/status sync i dokumentacja obserwowalnosci bez nowej migracji — Phase 132
|
||||
- [x] Parytet Erli w powierzchniach wspolnych: filtr zrodla zamowien, kanaly statystyk dziennych/podsumowania, warunek integracji automatyzacji, menu integracji i etykiety `zrodlo` uzywaja wspolnego rejestru zrodel — Phase 133
|
||||
- [x] Backlog Reality Check: `.paul/codebase/todo.md` i `.paul/codebase/concerns.md` sklasyfikowane przeciw aktualnemu kodowi/docs, z dowodami w `BACKLOG-AUDIT.md` i routingiem do faz 135-142 — Phase 134
|
||||
- [x] Accounting Net Correctness: nowe paragony zapisuja VAT-aware `receipts.total_net`, a statystyki dzienne preferuja source-level net, potem `order_items` VAT fallback, z gross `/1.23` tylko jako legacy fallback — Phase 135
|
||||
- [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127
|
||||
- [x] polkurier ShipmentService + TrackingService + UI prepare panel: pelen kontrakt API (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers), `PolkurierShipmentService` implementujacy `ShipmentProviderInterface` z normalizacja shipmenttype (lowercase) i splitem ulicy na street/housenumber/flatnumber, `PolkurierTrackingService` mapujacy statusy O/P/A/WP/D/Z/W na znormalizowane, panel "polkurier" w `prepare.php` z dynamiczna lista uslug z `available_carriers`, seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami z PDF v1.11; live test na #114/#115 zakonczony sukcesem po 4 iteracjach (ReferenceError → uppercase shipmenttype → orderno parsing → A4/A6); rozmiar etykiety sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — Phase 128
|
||||
- [x] Order User Notes module (Phase 129): pelen CRUD notatek autorskich operatora per zamowienie. Reuse `order_notes` przez nowy `note_type='user'` z `user_id` (FK→users SET NULL) + `author_name` (snapshot) + indeks `idx_order_notes_type_order`. `OrderNotesService` z autoryzacja DB-level (`WHERE user_id = :user_id`, rowCount=0 ⇒ 403). Sekcja `#notes` w "Wiadomosci i zalaczniki" w `/orders/{id}` z inline edit form + delete przez `OrderProAlerts.confirm`. Badge `[N]` (indigo neutralny) przy nr zamowienia na `/orders/list` (subquery `user_notes_count` w paginate). Brak admin override (brak systemu rol w aplikacji) — edit/delete tylko dla autora — Phase 129
|
||||
@@ -142,11 +143,11 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
### Deferred
|
||||
|
||||
- [ ] Eliminacja zduplikowanego kodu: SslCertificateResolver, ToggleableRepositoryTrait, RedirectPathResolver, ReceiptService — Phase 68
|
||||
- [ ] STAT-NET: pobieranie netto z shopPRO lub wyliczanie z `order_items.tax_rate` (unikniecie sztywnego 23% VAT) — `.paul/TODO.md`
|
||||
- [ ] Historical receipt net backfill: pominięte w Phase 135 decyzja operatora; wracac tylko jesli stare paragony maja byc korygowane raportowo
|
||||
|
||||
### Active (In Progress)
|
||||
|
||||
- [ ] v3.9 Stabilizacja i splata dlugu technicznego — Phase 135 Accounting Net Correctness ready to plan after Phase 134 audit.
|
||||
- [ ] v3.9 Stabilizacja i splata dlugu technicznego — Phase 136 Fakturownia Invoice Idempotency ready to plan after Phase 135.
|
||||
|
||||
### Planned (Next)
|
||||
|
||||
@@ -225,7 +226,7 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
|
||||
| Re-import istniejacego zamowienia jest delta-only — `replaceAddresses/Items/Notes` tylko przy `created=true`; `updateOrderDelta()` zawezony do payment_status/total_paid/status_code/is_canceled_by_buyer/source_updated_at/payload_json/fetched_at | Zamowienia zarzadzane sa w orderPRO (nie w zrodle), wiec re-import nie powinien nadpisywac stanu lokalnego ani lamac stabilnosci `order_items.id` (case #882: znikajace `project_generated`) | 2026-05-07 | Active |
|
||||
| Identical-payload no-op guard w re-imporcie via `normalizePayloadJson()` (decode->encode->compare) | Eliminacja niepotrzebnych write'ow do binloga/replikacji przy cyklicznym imporcie tych samych zamowien; fail-open gdy klucze JSON sa reorderowane miedzy syncami | 2026-05-07 | Active |
|
||||
| Statistics channelSql: explicit `COLLATE utf8mb4_unicode_ci` na CASE z `CAST(integration_id AS CHAR)` | Unikniecie `1271 Illegal mix of collations` w `IN (...)` z parametrami bindowanymi; pattern dla przyszlych agregacji per-integration | 2026-04-19 | Active |
|
||||
| Statistics netto fallback `ROUND(gross / 1.23, 2)` gdy `total_without_tax` puste | shopPRO nie wysyla netto ani w zamowieniu ani w `order_items`; tymczasowy fallback — docelowy fix w `.paul/TODO.md` (STAT-NET) | 2026-04-19 | Active |
|
||||
| Statistics netto fallback `ROUND(gross / 1.23, 2)` gdy `total_without_tax` puste | Superseded by Phase 135: statystyki preferuja source-level net i `order_items` VAT fallback; gross `/1.23` zostaje tylko dla legacy bez uzywalnych itemow | 2026-04-19 | Superseded |
|
||||
| ON DUPLICATE KEY UPDATE created_at = created_at dla idempotentnego markSent() | Unikniece silent failure i race condition przy rownolegych cronach; thread-safe bez wyjatkow | 2026-04-25 | Active |
|
||||
| send_once_per_order opt-in przez checkbox (domyslnie off) | Wsteczna zgodnosc — istniejace reguly nie zmieniaja zachowania; markSent() tylko po sukcesie wysylki | 2026-04-25 | Active |
|
||||
| DeliveryStatus::setRepository() pattern: DB fallback dla static final class | Operator dodaje status w UI bez zmian kodu; `getAllOptions()`/`label()`/`getColor()` ladują z DB gdy repo ustawione, fallback na hardcoded ALL_STATUSES/LABEL_PL | 2026-04-27 | Active |
|
||||
@@ -263,6 +264,8 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
|
||||
| Erli hardening uzywa istniejacych powierzchni obserwowalnosci zamiast nowej tabeli logow | Operator wybral ujednolicenie istniejacych miejsc; `integration_order_sync_state.last_error`, wynik crona i activity log wystarczaja dla Phase 132 | 2026-05-16 | Active |
|
||||
| Zrodla zamowien marketplace maja wspolny `OrderSourceRegistry` | Parytet Erli ma byc utrzymany wszedzie tam, gdzie kod potrzebuje listy lub etykiety zrodla; lokalne pary Allegro/shopPRO prowadzily do pominiec Erli | 2026-05-16 | Active |
|
||||
| v3.9 debt phases start from evidence-backed backlog audit | Phase 134 rozdzielil wpisy aktywne, wdrozone, stale i decyzyjne; kolejne fazy 135-142 maja naprawiac tylko potwierdzone problemy | 2026-05-16 | Active |
|
||||
| Existing receipt `total_net` rows are not backfilled | Operator wybral zakres Phase 135 tylko dla nowych paragonow; historia pozostaje bez migracji/UPDATE | 2026-05-16 | Active |
|
||||
| Accounting net fallbacks prefer explicit source data before assumptions | Phase 135: source-level net > item net/gross+VAT > legacy gross `/1.23`; dostawa fallback jako 23% VAT | 2026-05-16 | Active |
|
||||
| polkurier startuje jako jedna globalna konfiguracja (single-instance, mirror Apaczka/HostedSMS/SMSPLANET) z realnym testowym wywolaniem `apimetod=test_auth_api` | Operator ma jedno konto polkurier; fundament musi byc zweryfikowany na zywym API zanim dolozymy `PolkurierShipmentService` | 2026-05-14 | Active |
|
||||
| polkurier wymaga `login + token` razem w body `authorization` (nie samego tokena) | Zweryfikowane w SDK polkurier-sdk (`Auth.php`/`Request.php`); kolumna `login VARCHAR(190)` w `polkurier_integration_settings` mimo ze PLAN tego nie wymagal — kontrakt API to dyktuje | 2026-05-14 | Active |
|
||||
| polkurier API: top-level `status` === `'success'` (nie `'ok'`), tresc bledu w polu `response` envelope'a | `ResponseStatus::SUCCESS = 'success'` z `src/Type/ResponseStatus.php` SDK; bledy rzucane przez `ErrorException($response->get('response'))` w `PolkurierWebService.php`. Pattern dla wszystkich przyszlych metod polkurier API (`createShipment`, `getLabel`, `getStatus`, `cancelOrder`, etc.) | 2026-05-14 | Active |
|
||||
@@ -310,6 +313,6 @@ Quick Reference:
|
||||
|
||||
---
|
||||
*PROJECT.md — Updated when requirements or context change*
|
||||
*Last updated: 2026-05-16 after Phase 134 (Backlog Reality Check) closure*
|
||||
*Last updated: 2026-05-16 after Phase 135 (Accounting Net Correctness) closure*
|
||||
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@ Milestone porzadkujacy zbudowany z `.paul/codebase/todo.md` i `.paul/codebase/co
|
||||
|
||||
Rule for every phase/plan: przed implementacja sprawdzic w kodzie i dokumentacji, czy wpis nadal jest aktualny i czy nie zostal juz wdrozony; nastepnie przedstawic krotki plan operatorowi i zapytac o potwierdzenie. Dopiero po akceptacji wolno wprowadzac zmiany i uruchamiac testy. Jezeli wpis jest nieaktualny albo juz zrealizowany, faza/planu ma zamknac go dokumentacyjnie bez niepotrzebnej zmiany kodu.
|
||||
|
||||
Progress: 1 of 9 phases complete (11%).
|
||||
Progress: 2 of 9 phases complete (22%).
|
||||
|
||||
| Phase | Name | Plans | Status |
|
||||
|-------|------|-------|--------|
|
||||
| 134 | Backlog Reality Check | 1/1 | Complete (2026-05-16; documentation-only audit, Sonar CLI gap documented) |
|
||||
| 135 | Accounting Net Correctness | TBD | Ready to plan |
|
||||
| 135 | Accounting Net Correctness | 1/1 | Complete (2026-05-16; VAT-aware receipt/stat net, PHPUnit/Sonar env gaps documented) |
|
||||
| 136 | Fakturownia Invoice Idempotency | TBD | Not started |
|
||||
| 137 | Delivery Status Backlog Verification | TBD | Not started |
|
||||
| 138 | Security and Legacy Hardening | TBD | Not started |
|
||||
@@ -34,7 +34,7 @@ Plans: 134-01 (complete; `.paul/phases/134-backlog-reality-check/134-01-SUMMARY.
|
||||
### Phase 135: Accounting Net Correctness
|
||||
|
||||
Focus: Poprawic znane rozbieznosci kwot netto: `RECEIPT-NET-FIX` dla `receipts.total_net` oraz `STAT-NET` dla statystyk zamowien bez stalego zalozenia 23% VAT. Zakres obejmuje ustalenie zrodla prawdy, ewentualny backfill i testy eksportow/statystyk.
|
||||
Plans: TBD (defined during $paul-plan)
|
||||
Plans: 135-01 (complete; `.paul/phases/135-accounting-net-correctness/135-01-SUMMARY.md`)
|
||||
|
||||
### Phase 136: Fakturownia Invoice Idempotency
|
||||
|
||||
@@ -168,7 +168,7 @@ Kandydaci w kolejce (po v3.8):
|
||||
- Mobile Orders List / Mobile Order Details / Mobile Settings
|
||||
- Zarzadzanie produktami
|
||||
- Zarzadzanie stanami magazynowymi
|
||||
- STAT-NET (netto shopPRO bez fallbacku 23%)
|
||||
- Historical receipt net backfill, only if operator later wants old `receipts.total_net` corrected
|
||||
- Phase 68 — Code Deduplication Refactor
|
||||
|
||||
## Completed Milestones
|
||||
@@ -634,4 +634,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-12*
|
||||
*Last updated: 2026-05-16 - Phase 134 complete; Phase 135 ready to plan*
|
||||
*Last updated: 2026-05-16 - Phase 135 complete; Phase 136 ready to plan*
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
See: .paul/PROJECT.md (updated 2026-05-16)
|
||||
|
||||
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
|
||||
**Current focus:** v3.9 Stabilizacja i splata dlugu technicznego; Phase 134 backlog audit complete, Phase 135 ready to plan.
|
||||
**Current focus:** v3.9 Stabilizacja i splata dlugu technicznego; Phase 136 Fakturownia Invoice Idempotency ready to plan.
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v3.9 Stabilizacja i splata dlugu technicznego
|
||||
Phase: 135 of 142 (Accounting Net Correctness) - Ready to plan
|
||||
Phase: 136 of 142 (Fakturownia Invoice Idempotency) - Ready to plan
|
||||
Plan: Not started
|
||||
Status: Phase 134 complete; ready for PLAN
|
||||
Last activity: 2026-05-16 20:49 - Unified .paul/phases/134-backlog-reality-check/134-01-PLAN.md
|
||||
Status: Ready for next PLAN
|
||||
Last activity: 2026-05-16 21:51 - Phase 135 complete, transitioned to Phase 136
|
||||
|
||||
Progress:
|
||||
- Milestone v3.9: [#---------] 11% (1 of 9 phases complete)
|
||||
- Phase 135: [----------] 0% (ready to plan)
|
||||
- Milestone v3.9: [##--------] 22% (2 of 9 phases complete)
|
||||
- Phase 136: [----------] 0% (not started)
|
||||
|
||||
## Loop Position
|
||||
|
||||
@@ -29,18 +29,18 @@ PLAN -> APPLY -> UNIFY
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-16 20:49
|
||||
Stopped at: Phase 134 complete, ready to plan Phase 135
|
||||
Next action: $paul-plan for Phase 135 Accounting Net Correctness
|
||||
Resume file: .paul/phases/134-backlog-reality-check/134-01-SUMMARY.md
|
||||
Last session: 2026-05-16 21:51
|
||||
Stopped at: Phase 135 complete, ready to plan Phase 136
|
||||
Next action: $paul-plan for Phase 136
|
||||
Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Pending parallel work
|
||||
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
|
||||
|
||||
## Git State
|
||||
|
||||
Last phase commit: feat(134): backlog reality check
|
||||
Previous: 0c1246b feat(133): erli cross-surface parity
|
||||
Last phase commit: feat(135): accounting net correctness
|
||||
Previous: 53f01c3 feat(134): backlog reality check
|
||||
Branch: main
|
||||
|
||||
### Skill Audit (Phase 129)
|
||||
@@ -79,24 +79,33 @@ Branch: main
|
||||
|----------|---------|-------|
|
||||
| `sonar-scanner` | gap documented | Attempted after APPLY with `sonar-scanner --version`; CLI is not available in PATH. |
|
||||
|
||||
### Skill Audit (Phase 135)
|
||||
|
||||
| Expected | Invoked | Notes |
|
||||
|----------|---------|-------|
|
||||
| `sonar-scanner` | gap documented | Attempted after APPLY with `sonar-scanner --version`; CLI is not available in PATH. |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Recent Decisions
|
||||
|
||||
- Phase 134 is documentation-only: no runtime code or schema changes were made.
|
||||
- Backlog entries are annotated, not deleted; stale/implemented cleanup is deferred to later phases.
|
||||
- Phase 135 should start with confirmed accounting net issues: `RECEIPT-NET-FIX` and `STAT-NET`.
|
||||
- Phase 135 completed confirmed accounting net issues: `RECEIPT-NET-FIX` and `STAT-NET` are resolved for new/runtime behavior.
|
||||
- Phase 135 applies only to new receipts; historical receipt `total_net` rows are not backfilled by operator decision.
|
||||
- Phase 135 delivery net fallback uses 23% VAT when no source-level delivery VAT exists.
|
||||
- Phase 139 must refresh Sonar before cleanup because the current concern counts are a stale baseline.
|
||||
|
||||
### Blockers / Concerns
|
||||
|
||||
- Phase 134: `sonar-scanner` is still unavailable in PATH.
|
||||
- Phase 135: `vendor/bin/phpunit` and `sonar-scanner` are unavailable in PATH/checkout; syntax checks and ad-hoc SQLite/runtime smoke passed.
|
||||
- Phase 136: Fakturownia idempotency strategy needs API/operator confirmation before code changes.
|
||||
- Phase 140: deferred indexes should be applied only after operator confirms dataset size/prod timing.
|
||||
|
||||
### Deferred Issues
|
||||
|
||||
- Backlog items and concern groups classified in `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`; implementation deferred to phases 135-142.
|
||||
- Backlog items and concern groups classified in `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`; remaining implementation deferred to phases 136-142.
|
||||
|
||||
## Pending Actions
|
||||
|
||||
@@ -111,7 +120,6 @@ Branch: main
|
||||
- Phase 121 transition note: git commit was not created during UNIFY because the worktree contains unrelated Phase 118/local dirty files; prepare a scoped commit manually.
|
||||
- Phase 122 follow-up: manually verify settings save/reload and real SMSPLANET test/order sends with non-empty and empty footer; manually trigger over-limit final body rejection in UI.
|
||||
- Phase 123 follow-up: wystaw nowy paragon i potwierdz `items_json` zawiera `vat` per pozycja; eksport XLSX z paragonem multi-rate (np. mix 23% + 8%) — sprawdz osobne wiersze; eksport "wybrane paragony" zachowuje breakdown.
|
||||
- Phase 123 deferred: RECEIPT-NET-FIX (`ReceiptService::issue()` zapisuje `total_net=total_gross`) — udokumentowane w `.paul/codebase/todo.md`.
|
||||
- Phase 124 follow-up: `php bin/migrate.php` (XAMPP MySQL online) — utworzy `sms_templates`. Operator nastepnie tworzy szablony manualnie z `/settings/sms-templates`.
|
||||
- Phase 124 follow-up: real smoke wysylki SMS z szablonu (zamowienie z paczka + skonfigurowana stopka SMSPLANET) — sprawdzic ze `sms_messages.body` ma stopke raz, finalna tresc <= 918 znakow.
|
||||
- Phase 124 follow-up: regresja Email — wyslij e-mail z istniejacym szablonem aby potwierdzic ze refaktor `Email\VariableResolver` na fasade nie zlamal `EmailSendingService`.
|
||||
@@ -152,10 +160,10 @@ Branch: main
|
||||
## Deferred to Next Milestones
|
||||
|
||||
- Phase 68 - Code Deduplication Refactor (0/2 Planning, nigdy nie rozpoczety).
|
||||
- STAT-NET - netto shopPRO z API lub z `order_items.tax_rate` (`.paul/TODO.md`).
|
||||
- Historical receipt net backfill - skipped in Phase 135 by operator decision; revisit only if needed for old receipt reporting.
|
||||
- Mobile Orders List / Mobile Order Details / Mobile Settings.
|
||||
- INDEX-106-01 - indeksy DB dla query `customer_returned_count`: `order_addresses(order_id, address_type)`, `shipment_packages(order_id, delivery_status)` (gdy dataset >50k wierszy).
|
||||
|
||||
## Skill Requirements
|
||||
|
||||
- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130, Phase 131, Phase 132, Phase 133 and Phase 134 gaps documented because CLI was not available in PATH.
|
||||
- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130, Phase 131, Phase 132, Phase 133, Phase 134 and Phase 135 gaps documented because CLI was not available in PATH.
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
- Oznaczono aktywne wpisy `RECEIPT-NET-FIX`, `STAT-NET` i `INVOICE-IDEMP-115`; `DELIVERY-STATUS-MGMT` sklasyfikowano jako wdrozone z pozostala weryfikacja operatorska.
|
||||
- Sklasyfikowano concerns: stale liczby Sonar, aktywne ryzyka TLS/indexow/cron backoff, czesciowo wdrozone elementy `SslCertificateResolver` i `RedirectPathResolver`.
|
||||
- Udokumentowano gap srodowiskowy Phase 134: brak `sonar-scanner` w PATH; kod runtime nie byl zmieniany.
|
||||
- [Phase 135, Plan 01] Domknieto Accounting Net Correctness: nowe paragony zapisuja VAT-aware `receipts.total_net`, a statystyki dzienne licza netto z source-level net albo `order_items` VAT fallback.
|
||||
- `ReceiptService::buildItemsSnapshot()` zwraca teraz `total_net` i `total_gross`; dostawa jest liczona jako 23% VAT zgodnie z decyzja operatora.
|
||||
- `OrdersStatisticsRepository::netAmountSql()` preferuje `orders.total_without_tax`, potem `orders.total_net`, potem item-level net/gross+VAT, a gross `/1.23` zostawia tylko jako legacy fallback.
|
||||
- Dodano test mixed-VAT paragonu oraz testy statystyk dla source-net precedence, mixed-VAT fallback i legacy gross fallback.
|
||||
- Udokumentowano gapy srodowiskowe Phase 135: brak `vendor/bin/phpunit` i brak `sonar-scanner` w PATH.
|
||||
|
||||
## Zmienione pliki
|
||||
|
||||
@@ -84,16 +89,20 @@
|
||||
- `.paul/phases/134-backlog-reality-check/134-01-PLAN.md`
|
||||
- `.paul/phases/134-backlog-reality-check/134-01-SUMMARY.md`
|
||||
- `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`
|
||||
- `.paul/phases/135-accounting-net-correctness/135-01-PLAN.md`
|
||||
- `.paul/phases/135-accounting-net-correctness/135-01-SUMMARY.md`
|
||||
- `.paul/codebase/todo.md`
|
||||
- `.paul/codebase/concerns.md`
|
||||
- `src/Modules/Orders/OrderSourceRegistry.php`
|
||||
- `src/Modules/Orders/OrdersRepository.php`
|
||||
- `src/Modules/Orders/OrdersController.php`
|
||||
- `src/Modules/Statistics/OrdersStatisticsRepository.php`
|
||||
- `src/Modules/Accounting/ReceiptService.php`
|
||||
- `src/Modules/Automation/AutomationRepository.php`
|
||||
- `src/Modules/Accounting/InvoiceService.php`
|
||||
- `src/Modules/Settings/EmailTemplateController.php`
|
||||
- `src/Modules/Settings/SmsTemplateController.php`
|
||||
- `resources/views/layouts/app.php`
|
||||
- `tests/Unit/ReceiptServiceNetCalculationTest.php`
|
||||
- `tests/Unit/OrderSourceRegistryTest.php`
|
||||
- `tests/Unit/OrdersStatisticsRepositoryTest.php`
|
||||
|
||||
@@ -480,6 +480,20 @@ tests/
|
||||
- Multi-rate paragon = wiele wierszy w XLSX (ten sam Numer, Data wystawienia i Kwota brutto powtarzane).
|
||||
- Helper `formatVatRate()` formatuje stawke (23.0 -> "23%", 7.5 -> "7.5%").
|
||||
|
||||
## Phase 135 — Accounting Net Correctness
|
||||
|
||||
### ReceiptService::buildItemsSnapshot (`src/Modules/Accounting/ReceiptService.php`)
|
||||
- Dla nowych paragonow snapshot nadal zachowuje kontrakt `name`, `quantity`, `price`, `total`, `vat`, `sku`, `ean`.
|
||||
- Metoda zwraca teraz takze `total_net`; `ReceiptService::issue()` zapisuje `receipts.total_net` z sumy netto per linia zamiast kopiowac brutto.
|
||||
- Netto pozycji: `lineGross / (1 + vat/100)` z VAT z `tax_rate`/`vat`; brak stawki oznacza fallback 23.0.
|
||||
- Koszt wysylki pozostaje osobna pozycja "Koszt wysylki" z VAT 23.0; operator zdecydowal, ze historyczne paragony nie sa backfillowane.
|
||||
|
||||
### OrdersStatisticsRepository::netAmountSql (`src/Modules/Statistics/OrdersStatisticsRepository.php`)
|
||||
- Statystyki dzienne preferuja `orders.total_without_tax`, potem `orders.total_net`, jezeli wartosc jest dodatnia.
|
||||
- Gdy net z zamowienia jest pusty, repozytorium liczy fallback z `order_items`: najpierw `original_price_without_tax * quantity`, potem `original_price_with_tax * quantity / (1 + tax_rate/100)`.
|
||||
- Dla pozycji bez VAT fallback stawki wynosi 23.0; dostawa bez osobnej stawki jest doliczana jako `delivery_price / 1.23`.
|
||||
- Stare `gross / 1.23` pozostaje tylko jako ostatni fallback dla legacy zamowien bez uzywalnych pozycji.
|
||||
|
||||
## Phase 120 — Alert Component Unification
|
||||
|
||||
### Alert component (`resources/views/components/alert.php`)
|
||||
|
||||
@@ -11,7 +11,7 @@ Szczegoly i dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
|
||||
| Breaking: delivery status group keys | **Active operational follow-up** | DB-driven statusy sa wdrozone, ale stare reguly automation moga wymagac odtworzenia przez operatora. |
|
||||
| Breaking: `SHIPMENT_STATUS_OPTION_MAP` | **Implemented / stale** | Symbol nie wystepuje juz w runtime source. |
|
||||
| Breaking: `_csrf_token` -> `_token` | **Implemented / stale** | Formularze/kontrolery uzywaja `_token`; wewnetrzny session key w `Csrf` nie jest problemem formularzy. |
|
||||
| Known Bugs: `STAT-NET` | **Active** | Przeniesione do Phase 135 razem z `RECEIPT-NET-FIX`. |
|
||||
| Known Bugs: `STAT-NET` | **Resolved in Phase 135** | Runtime statystyk liczy net z source-level net albo item-level VAT; `RECEIPT-NET-FIX` naprawiony dla nowych paragonow bez backfillu historii. |
|
||||
| Deferred Indexes | **Active / deferred** | Indeksy nadal nie sa w migracjach; wykonac po decyzji operatora w Phase 140. |
|
||||
| Security: print API keys | **Implemented / stale** | Przechowywany jest hash i prefix, nie raw `api_key`. |
|
||||
| Security: mailbox TLS | **Active, wording stale** | Nie ma juz `fsockopen`, ale `stream_socket_client()` ma `verify_peer=false`. |
|
||||
@@ -66,8 +66,8 @@ Szczegoly i dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
|
||||
|
||||
| Issue | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| `STAT-NET`: hardcoded 23% VAT fallback for net calculations | `src/Modules/Statistics/OrdersStatisticsRepository.php:471` | Deferred (`.paul/TODO.md`) |
|
||||
| Missing net amounts for shopPRO orders | `.paul/TODO.md` (STAT-NET) | Deferred |
|
||||
| `STAT-NET`: hardcoded 23% VAT fallback for net calculations | `src/Modules/Statistics/OrdersStatisticsRepository.php` | Fixed in Phase 135; gross `/1.23` remains only as legacy fallback without usable items |
|
||||
| Missing net amounts for shopPRO orders | `.paul/codebase/todo.md` (STAT-NET) | Runtime fallback fixed in Phase 135 through `order_items` VAT calculation |
|
||||
| `order.status_aged` condition fallback | `AutomationService` | Fixed 2026-04-25 |
|
||||
|
||||
## Deferred Indexes (Phase 106)
|
||||
|
||||
@@ -717,7 +717,7 @@ Indexes: `sms_templates_active_name_idx (is_active, name)` — supports active-t
|
||||
| `seller_data_json` | JSON | NO | Snapshot of company data at issue time |
|
||||
| `buyer_data_json` | JSON | YES | |
|
||||
| `items_json` | JSON | NO | |
|
||||
| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
|
||||
| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00; Phase 135: new receipts store VAT-aware net sum, no historical backfill |
|
||||
| `total_gross` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
|
||||
| `order_reference_value` | VARCHAR(128) | YES | |
|
||||
| `created_by` | INT UNSIGNED | YES | |
|
||||
@@ -1049,4 +1049,5 @@ Default keys: `cron_run_on_web`, `cron_web_limit`, `gs1_api_login`, `gs1_prefix`
|
||||
- Uses `integrations.name` only for display labels when available.
|
||||
- Filters by selected status groups through `order_status_groups` and `order_statuses`.
|
||||
- Uses existing gross amount columns via `OrdersStatisticsRepository::grossAmountSql()`.
|
||||
- Daily `/statistics/orders` net totals prefer `orders.total_without_tax`, then `orders.total_net`; when source net is missing, Phase 135 computes fallback from `order_items` net/gross values and VAT rates plus delivery net at 23% VAT. Gross `/1.23` remains only for legacy rows without usable items.
|
||||
- No schema migration was introduced for Phase 110.
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Technical Changelog
|
||||
|
||||
## 2026-05-16 - Phase 135 Plan 01: Accounting Net Correctness
|
||||
|
||||
**Co zrobiono:**
|
||||
- `ReceiptService::buildItemsSnapshot()` zwraca teraz `total_net` obok `total_gross`, liczac netto per pozycja po realnej stawce VAT z `tax_rate`/`vat`.
|
||||
- `ReceiptService::issue()` zapisuje nowe `receipts.total_net` z obliczonej sumy netto zamiast kopiowac kwote brutto.
|
||||
- Koszt wysylki w paragonach i statystykach pozostaje traktowany jako 23% VAT.
|
||||
- `OrdersStatisticsRepository::netAmountSql()` preferuje `orders.total_without_tax`, potem `orders.total_net`, a przy braku net zrodla liczy fallback z `order_items` (`original_price_without_tax` albo brutto/VAT/ilosc).
|
||||
- Dodano test `ReceiptServiceNetCalculationTest` oraz rozszerzono `OrdersStatisticsRepositoryTest` o source-net precedence i mieszane stawki VAT.
|
||||
|
||||
**Dlaczego:**
|
||||
- Phase 134 potwierdzil aktywne bugi `RECEIPT-NET-FIX` i `STAT-NET`: paragony zapisywaly netto jako kopie brutto, a statystyki zakladaly 23% VAT dla wszystkich zamowien bez source-level net.
|
||||
|
||||
**BREAKING / migracja:**
|
||||
- Brak migracji i brak breaking changes. Operator wybral brak backfillu historycznych paragonow; zmiana dotyczy nowych paragonow i runtime statystyk.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-16 - Phase 129 Plan 01: Erli Status Mapping + Sync
|
||||
|
||||
**Co zrobiono:**
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
### Status audytu Phase 134 (2026-05-16)
|
||||
- **Active** - `ReceiptService::issue()` nadal zapisuje `total_net` jako kopie brutto, a `buildItemsSnapshot()` nie zwraca netto. Dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
|
||||
|
||||
### Status Phase 135 (2026-05-16)
|
||||
- **Resolved for new receipts** - `ReceiptService::buildItemsSnapshot()` zwraca `total_net`, a `ReceiptService::issue()` zapisuje realne netto dla nowych paragonow. Historyczne paragony nie sa backfillowane decyzja operatora z planu 135-01.
|
||||
|
||||
### Kontekst
|
||||
- Phase 123-01 — eksport paragonow XLSX z VAT breakdown.
|
||||
- `ReceiptService::issue()` (linie 81-82) zapisuje `total_net = total_gross` (kopia, nie realne netto). To znany bug, ale nie poprawiany w 123 (poza zakresem).
|
||||
@@ -44,6 +47,9 @@
|
||||
### Status audytu Phase 134 (2026-05-16)
|
||||
- **Active** - `OrdersStatisticsRepository::netAmountSql()` nadal ma fallback `gross / 1.23`; mapper potrafi przyjac czesc pol netto/VAT, ale statystyki nie licza netto po pozycjach. Dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
|
||||
|
||||
### Status Phase 135 (2026-05-16)
|
||||
- **Resolved for runtime statistics** - `OrdersStatisticsRepository::netAmountSql()` preferuje source-level net, a przy braku net liczy fallback z `order_items` po realnym VAT i dolicza dostawe jako 23% VAT. Stale `gross / 1.23` zostaje tylko jako ostatni fallback dla legacy zamowien bez pozycji.
|
||||
|
||||
### Kontekst
|
||||
- Statystyki `/statistics/orders` pokazuja `Netto` per dzien/kanal.
|
||||
- shopPRO nie wysyla kwoty netto ani na poziomie zamowienia (`orders.total_without_tax`), ani produktow (`order_items.original_price_without_tax` — rowniez puste).
|
||||
|
||||
217
.paul/phases/135-accounting-net-correctness/135-01-PLAN.md
Normal file
217
.paul/phases/135-accounting-net-correctness/135-01-PLAN.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
phase: 135-accounting-net-correctness
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/Modules/Accounting/ReceiptService.php
|
||||
- src/Modules/Statistics/OrdersStatisticsRepository.php
|
||||
- tests/Unit/ReceiptServiceNetCalculationTest.php
|
||||
- tests/Unit/OrdersStatisticsRepositoryTest.php
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/DB_SCHEMA.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
- .paul/codebase/architecture.md
|
||||
- .paul/codebase/db_schema.md
|
||||
- .paul/codebase/tech_changelog.md
|
||||
- .paul/codebase/todo.md
|
||||
- .paul/codebase/concerns.md
|
||||
autonomous: true
|
||||
delegation: auto
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Fix confirmed accounting net amount drift for new receipts and order statistics in one vertical plan.
|
||||
|
||||
## Purpose
|
||||
Phase 134 confirmed two active bugs: `RECEIPT-NET-FIX` and `STAT-NET`. Operators need receipt records and statistics to show net values that follow real VAT rates instead of silently copying gross or assuming all sales are 23% VAT.
|
||||
|
||||
## Output
|
||||
- New receipts store `receipts.total_net` as a VAT-aware net sum.
|
||||
- `/statistics/orders` daily net aggregation prefers source net and otherwise computes from order items with their tax rates.
|
||||
- Tests cover receipt net calculation and statistics mixed-VAT fallback.
|
||||
- Technical docs and PAUL backlog annotations reflect the new contract.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
<clarifications>
|
||||
- **Backfill** - Czy Phase 135 ma naprawic takze istniejace rekordy `receipts.total_net`, czy tylko nowe paragony od tej zmiany?
|
||||
-> Odpowiedz: Tylko nowe paragony.
|
||||
- **Dostawa** - Jak liczyc netto kosztu dostawy, gdy zrodlo nie daje osobnej stawki VAT dla wysylki?
|
||||
-> Odpowiedz: Liczyc dostawe jako 23% VAT.
|
||||
- **Zakres** - Czy przygotowac jeden plan obejmujacy `RECEIPT-NET-FIX` i `STAT-NET`, czy rozbic Phase 135 na osobne plany?
|
||||
-> Odpowiedz: Jeden plan obejmujacy `RECEIPT-NET-FIX` i `STAT-NET`.
|
||||
</clarifications>
|
||||
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
@.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md
|
||||
@.paul/codebase/todo.md
|
||||
@.paul/codebase/concerns.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
@DOCS/ARCHITECTURE.md
|
||||
@DOCS/DB_SCHEMA.md
|
||||
|
||||
## Source Files
|
||||
@src/Modules/Accounting/ReceiptService.php
|
||||
@src/Modules/Accounting/ReceiptRepository.php
|
||||
@src/Modules/Accounting/AccountingController.php
|
||||
@src/Modules/Accounting/InvoiceService.php
|
||||
@src/Modules/Statistics/OrdersStatisticsRepository.php
|
||||
@src/Modules/Statistics/OrdersStatisticsController.php
|
||||
@src/Modules/Settings/ShopproOrderMapper.php
|
||||
@src/Modules/Settings/ErliOrderMapper.php
|
||||
@src/Modules/Orders/OrderImportRepository.php
|
||||
@tests/Unit/OrdersStatisticsRepositoryTest.php
|
||||
</context>
|
||||
|
||||
<skills>
|
||||
## Required Skills / Tooling
|
||||
|
||||
| Skill / Tool | Priority | When to Invoke | Loaded? |
|
||||
|--------------|----------|----------------|---------|
|
||||
| `sonar-scanner` | required | After APPLY, before UNIFY | pending |
|
||||
|
||||
If `sonar-scanner` is still unavailable in PATH, document the gap in SUMMARY/STATE as in phases 129-134.
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: New Receipt Net Is Real Net
|
||||
```gherkin
|
||||
Given an order with mixed VAT items and a delivery price
|
||||
When a new receipt is issued after this plan
|
||||
Then `receipts.total_net` equals the sum of per-line net amounts and delivery net at 23% VAT
|
||||
And `receipts.total_gross` remains the gross sum used before
|
||||
```
|
||||
|
||||
## AC-2: Existing Receipts Are Not Backfilled
|
||||
```gherkin
|
||||
Given historical receipt rows already exist
|
||||
When this plan is applied
|
||||
Then no migration or UPDATE rewrites existing `receipts.total_net`
|
||||
And legacy export behavior for old snapshots remains compatible
|
||||
```
|
||||
|
||||
## AC-3: Statistics Net Uses Source Or Item VAT
|
||||
```gherkin
|
||||
Given an order has source-level net amount
|
||||
When `/statistics/orders` aggregates daily totals
|
||||
Then the source net amount is used
|
||||
|
||||
Given an order has no source-level net amount but has order items with gross prices and VAT rates
|
||||
When `/statistics/orders` aggregates daily totals
|
||||
Then net is calculated from those item VAT rates plus delivery net at 23% VAT
|
||||
```
|
||||
|
||||
## AC-4: Verification And Docs Are Updated
|
||||
```gherkin
|
||||
Given the accounting net behavior changes
|
||||
When APPLY completes
|
||||
Then unit tests cover receipt and statistics net calculations
|
||||
And DOCS plus PAUL codebase notes describe the current behavior and remaining boundaries
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Store real net total for new receipts</name>
|
||||
<files>src/Modules/Accounting/ReceiptService.php, tests/Unit/ReceiptServiceNetCalculationTest.php</files>
|
||||
<action>
|
||||
Update `ReceiptService::buildItemsSnapshot()` to return `items`, `total_gross`, and `total_net`.
|
||||
- Compute each product line gross from `quantity * original_price_with_tax`.
|
||||
- Resolve VAT from `tax_rate` or `vat`, falling back to 23.0 only when missing.
|
||||
- Compute each line net as `lineGross / (1 + vat / 100)` when VAT is positive, otherwise gross.
|
||||
- Add delivery as a separate line with VAT 23.0, preserving the existing "Koszt wysylki" contract.
|
||||
- Use the returned `total_net` in `ReceiptRepository::create()` instead of copying gross.
|
||||
- Add a focused PHPUnit test using reflection/newInstanceWithoutConstructor for the private pure calculation helper, so final service dependencies do not need broad mocks.
|
||||
Avoid: any migration, backfill, or change to invoice generation; invoice net calculation is already separate and working.
|
||||
</action>
|
||||
<verify>`php -l src/Modules/Accounting/ReceiptService.php` and `vendor/bin/phpunit tests/Unit/ReceiptServiceNetCalculationTest.php` when PHPUnit is available</verify>
|
||||
<done>AC-1 and AC-2 satisfied for receipt generation.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Replace statistics hardcoded net fallback with VAT-aware aggregation</name>
|
||||
<files>src/Modules/Statistics/OrdersStatisticsRepository.php, tests/Unit/OrdersStatisticsRepositoryTest.php</files>
|
||||
<action>
|
||||
Refactor `OrdersStatisticsRepository::netAmountSql()` into readable helper fragments.
|
||||
- Prefer source-level net columns in order: `orders.total_without_tax`, then `orders.total_net`, when present and greater than zero.
|
||||
- If source-level net is missing, compute a correlated `order_items` sum for the order:
|
||||
- prefer `original_price_without_tax * quantity` when item net exists;
|
||||
- otherwise use `original_price_with_tax * quantity / (1 + tax_rate / 100)`;
|
||||
- fallback item VAT to 23.0 only for rows where tax rate is missing.
|
||||
- Add delivery net as `delivery_price / 1.23` when order-level net is missing, per clarification.
|
||||
- Keep a final gross fallback only for orders that have no usable item rows, and document it as last-resort legacy compatibility.
|
||||
- Preserve MySQL production syntax and SQLite unit-test compatibility.
|
||||
- Extend `OrdersStatisticsRepositoryTest` with mixed VAT and source-net-precedence cases.
|
||||
Avoid: changing statistics filters, channel registry behavior, status mapping, or monthly summary gross charts.
|
||||
</action>
|
||||
<verify>`php -l src/Modules/Statistics/OrdersStatisticsRepository.php` and `vendor/bin/phpunit tests/Unit/OrdersStatisticsRepositoryTest.php` when PHPUnit is available</verify>
|
||||
<done>AC-3 satisfied for daily statistics net totals.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Update documentation and backlog annotations</name>
|
||||
<files>DOCS/ARCHITECTURE.md, DOCS/DB_SCHEMA.md, DOCS/TECH_CHANGELOG.md, .paul/codebase/architecture.md, .paul/codebase/db_schema.md, .paul/codebase/tech_changelog.md, .paul/codebase/todo.md, .paul/codebase/concerns.md</files>
|
||||
<action>
|
||||
Update technical documentation after code changes.
|
||||
- Document the new receipt net calculation contract and the explicit no-backfill decision.
|
||||
- Document statistics net source precedence and item-level VAT fallback.
|
||||
- Mark `RECEIPT-NET-FIX` and `STAT-NET` as resolved for new/runtime behavior, with the historical receipt backfill explicitly deferred/skipped by operator choice.
|
||||
- Add a chronological entry to technical changelog explaining why the change was made.
|
||||
Avoid: deleting historical backlog text; keep concise status annotations and evidence links.
|
||||
</action>
|
||||
<verify>`rg -n "RECEIPT-NET-FIX|STAT-NET|total_net|VAT-aware|Phase 135" DOCS .paul/codebase`</verify>
|
||||
<done>AC-4 satisfied for documentation and backlog state.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- `database/migrations/*` - no receipt backfill or schema migration in this plan.
|
||||
- `src/Modules/Accounting/InvoiceService.php` - invoice net calculation is already VAT-aware.
|
||||
- Fakturownia delegated invoice flow and idempotency - belongs to Phase 136.
|
||||
- Statistics channel/source/status filters - Phase 133 behavior must remain intact.
|
||||
- Runtime database configuration - do not wire `DB_HOST_REMOTE` into application runtime.
|
||||
|
||||
## SCOPE LIMITS
|
||||
- Existing receipt rows are intentionally not rewritten.
|
||||
- No UI redesign, SCSS, or JavaScript changes.
|
||||
- No external shopPRO API capability research in this plan; use already imported order/item net and VAT fields.
|
||||
- No new dependencies.
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] `php -l src/Modules/Accounting/ReceiptService.php`
|
||||
- [ ] `php -l src/Modules/Statistics/OrdersStatisticsRepository.php`
|
||||
- [ ] `php -l tests/Unit/ReceiptServiceNetCalculationTest.php`
|
||||
- [ ] `php -l tests/Unit/OrdersStatisticsRepositoryTest.php`
|
||||
- [ ] `vendor/bin/phpunit tests/Unit/ReceiptServiceNetCalculationTest.php tests/Unit/OrdersStatisticsRepositoryTest.php` if PHPUnit exists in checkout
|
||||
- [ ] `git diff --check`
|
||||
- [ ] `sonar-scanner` after APPLY, or document unavailable CLI gap
|
||||
- [ ] All acceptance criteria met
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- New receipt net totals are VAT-aware and no longer copy gross.
|
||||
- Statistics daily net totals use source net or item VAT rates before any legacy gross fallback.
|
||||
- Existing receipt history remains untouched by design.
|
||||
- Focused tests and documentation are updated.
|
||||
- No unrelated runtime, schema, UI, or Fakturownia changes are introduced.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/135-accounting-net-correctness/135-01-SUMMARY.md`.
|
||||
</output>
|
||||
138
.paul/phases/135-accounting-net-correctness/135-01-SUMMARY.md
Normal file
138
.paul/phases/135-accounting-net-correctness/135-01-SUMMARY.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
phase: 135-accounting-net-correctness
|
||||
plan: 01
|
||||
subsystem: accounting-statistics
|
||||
tags: [accounting, receipts, statistics, vat, backlog-fix]
|
||||
requires:
|
||||
- phase: 134-backlog-reality-check
|
||||
provides: confirmed RECEIPT-NET-FIX and STAT-NET backlog inputs
|
||||
provides:
|
||||
- VAT-aware net totals for newly issued receipts
|
||||
- VAT-aware daily statistics net fallback from order items and delivery
|
||||
- Tests and documentation for accounting net source precedence
|
||||
affects: [receipts, statistics, documentation, paul-backlog]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [source-net-precedence, item-vat-net-fallback]
|
||||
key-files:
|
||||
created:
|
||||
- tests/Unit/ReceiptServiceNetCalculationTest.php
|
||||
modified:
|
||||
- src/Modules/Accounting/ReceiptService.php
|
||||
- src/Modules/Statistics/OrdersStatisticsRepository.php
|
||||
- tests/Unit/OrdersStatisticsRepositoryTest.php
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/DB_SCHEMA.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
- .paul/codebase/architecture.md
|
||||
- .paul/codebase/db_schema.md
|
||||
- .paul/codebase/tech_changelog.md
|
||||
- .paul/codebase/todo.md
|
||||
- .paul/codebase/concerns.md
|
||||
key-decisions:
|
||||
- "Existing receipts are not backfilled; only newly issued receipts store corrected total_net."
|
||||
- "Delivery net fallback uses 23% VAT when no delivery VAT source exists."
|
||||
- "Statistics prefer source-level net before item-level VAT fallback; gross / 1.23 remains last-resort legacy compatibility."
|
||||
patterns-established:
|
||||
- "Accounting net fallbacks must prefer explicit source data, then row-level VAT data, then documented legacy assumptions."
|
||||
duration: 42min
|
||||
started: 2026-05-16T21:09:00+02:00
|
||||
completed: 2026-05-16T21:51:00+02:00
|
||||
---
|
||||
|
||||
# Phase 135 Plan 01: Accounting Net Correctness Summary
|
||||
|
||||
New receipt issuance and daily order statistics now calculate net amounts from VAT-aware data instead of silently copying gross or assuming all order value is 23% VAT.
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~42min |
|
||||
| Started | 2026-05-16 21:09 |
|
||||
| Completed | 2026-05-16 21:51 |
|
||||
| Tasks | 3 completed |
|
||||
| Files created/modified | 13 plus PAUL metadata |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: New Receipt Net Is Real Net | Pass | `ReceiptService::buildItemsSnapshot()` now returns gross and net totals; mixed VAT item lines and delivery at 23% are included in `receipts.total_net`. |
|
||||
| AC-2: Existing Receipts Are Not Backfilled | Pass | No migration or data update was added; historical receipt rows remain unchanged by operator decision. |
|
||||
| AC-3: Statistics Net Uses Source Or Item VAT | Pass | `OrdersStatisticsRepository::netAmountSql()` prefers source net, then computes item VAT net plus delivery, with gross `/1.23` only as last fallback. |
|
||||
| AC-4: Verification And Docs Are Updated | Pass | Unit tests, DOCS, PAUL codebase notes, todo, and concerns were updated. |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Fixed new receipt snapshots so `total_net` is the rounded sum of item net amounts and delivery net, while `total_gross` keeps the existing gross behavior.
|
||||
- Added receipt calculation coverage for mixed 23% and 8% VAT items plus delivery.
|
||||
- Reworked daily statistics net SQL to prefer `orders.total_without_tax`/`orders.total_net`, then item-level net/gross+VAT calculations, then legacy gross fallback.
|
||||
- Extended statistics tests for source-net precedence, mixed-VAT fallback, and legacy gross fallback when item rows have no usable prices.
|
||||
- Marked `RECEIPT-NET-FIX` and `STAT-NET` as resolved for new/runtime behavior in PAUL backlog notes.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/Modules/Accounting/ReceiptService.php` | Modified | Store VAT-aware `total_net` for new receipts. |
|
||||
| `tests/Unit/ReceiptServiceNetCalculationTest.php` | Created | Covers mixed-VAT receipt net calculation. |
|
||||
| `src/Modules/Statistics/OrdersStatisticsRepository.php` | Modified | Adds source-net precedence and VAT-aware item fallback. |
|
||||
| `tests/Unit/OrdersStatisticsRepositoryTest.php` | Modified | Covers source net precedence and mixed-VAT statistics aggregation. |
|
||||
| `DOCS/ARCHITECTURE.md` | Modified | Documents receipt/statistics net behavior. |
|
||||
| `DOCS/DB_SCHEMA.md` | Modified | Documents `receipts.total_net` and reporting source precedence. |
|
||||
| `DOCS/TECH_CHANGELOG.md` | Modified | Adds Phase 135 technical changelog entry. |
|
||||
| `.paul/codebase/*.md` | Modified | Mirrors architecture, schema, changelog, todo, and concerns status updates. |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| No receipt backfill | Operator chose "only new receipts." | Existing `receipts.total_net` values may remain legacy/copy-gross. |
|
||||
| Delivery fallback at 23% VAT | Operator chose 23% when no delivery VAT source exists. | Receipt and statistics fallback behavior is consistent. |
|
||||
| Keep gross `/1.23` fallback | Some legacy orders may lack item/source net data. | Statistics remain compatible while preferring better data first. |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Scope additions | 0 | None. |
|
||||
| Deferred | 2 | PHPUnit and Sonar execution are blocked by missing local tooling. |
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| `vendor/bin/phpunit` is not present in the checkout. | Added tests, verified syntax, and ran an ad-hoc SQLite/runtime smoke for receipt and statistics calculations. |
|
||||
| `sonar-scanner` is not available in PATH. | Gap documented in this summary and `.paul/STATE.md`. |
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Command | Result |
|
||||
|---------|--------|
|
||||
| `php -l src\Modules\Accounting\ReceiptService.php` | Passed. |
|
||||
| `php -l src\Modules\Statistics\OrdersStatisticsRepository.php` | Passed. |
|
||||
| `php -l tests\Unit\ReceiptServiceNetCalculationTest.php` | Passed. |
|
||||
| `php -l tests\Unit\OrdersStatisticsRepositoryTest.php` | Passed. |
|
||||
| `vendor/bin/phpunit tests/Unit/ReceiptServiceNetCalculationTest.php tests/Unit/OrdersStatisticsRepositoryTest.php` | Not run; `vendor/bin/phpunit` is missing. |
|
||||
| Ad-hoc PHP/SQLite receipt + statistics smoke | Passed; receipt/statistics mixed-VAT paths produced net `310.00`, and legacy gross fallback produced `100.00`. |
|
||||
| `rg -n "RECEIPT-NET-FIX|STAT-NET|total_net|VAT-aware|Phase 135" DOCS .paul\codebase` | Passed. |
|
||||
| `git diff --check` | Passed; Git reported only LF/CRLF normalization warnings. |
|
||||
| `sonar-scanner --version` | Failed; CLI not available in PATH. |
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- Phase 135 implementation is unified and transitioned.
|
||||
- Phase 136 can start from the remaining Fakturownia idempotency backlog item.
|
||||
|
||||
**Concerns:**
|
||||
- Historical receipts remain intentionally uncorrected unless a later operator-approved backfill phase is created.
|
||||
- Full PHPUnit and Sonar verification still depend on local tooling setup.
|
||||
|
||||
**Blockers:**
|
||||
- None for UNIFY.
|
||||
|
||||
---
|
||||
*Phase: 135-accounting-net-correctness, Plan: 01*
|
||||
*Completed: 2026-05-16*
|
||||
@@ -88,6 +88,7 @@ HTTP Request
|
||||
### Statistics Summary
|
||||
|
||||
Phase 133 keeps Erli in the shared statistics channel contract: `listChannelOptions()` always exposes `erli`, and daily/monthly aggregation filters marketplace orders through `OrderSourceRegistry::marketplaceSources()`.
|
||||
Phase 135 fixes daily net totals: `OrdersStatisticsRepository::netAmountSql()` prefers source-level net (`total_without_tax`, then `total_net`) and otherwise computes net from `order_items` using item gross, quantity, and VAT rate. Delivery net is added as `delivery_price / 1.23` when source net is missing. The old gross `/ 1.23` path remains only as a last-resort fallback for legacy orders without usable item rows.
|
||||
|
||||
1. **Request** — `/statistics/summary` → `OrdersStatisticsController::summary()`
|
||||
2. **Filters** — controller reuses statistics filter semantics: date range, `channels[]`, `status_groups[]`, default status groups excluding cancelled; default history starts at `2026-04-01`.
|
||||
@@ -102,6 +103,8 @@ Phase 130 adds an Erli-specific post-label step: for `orders.source='erli'`, `Sh
|
||||
2. **Track** — Cron `ShipmentTrackingHandler` → `ShipmentTrackingRegistry` → carrier tracking API → optional Erli external parcel retry → `ShipmentPackageRepository::updateDeliveryStatus()` → shared `shipment.status_changed` automation event when normalized status really changes.
|
||||
|
||||
### Receipt / Invoice
|
||||
Phase 135 changes receipt generation for new documents: `ReceiptService::issue()` stores `receipts.total_net` as the VAT-aware sum of item net amounts, with delivery net calculated at 23% VAT. Existing receipts are intentionally not backfilled. Invoice generation already had a separate VAT-aware net calculation and is unchanged.
|
||||
|
||||
1. **Generate** — `ReceiptController::store()` → `ReceiptService::generateReceipt()` → `ReceiptRepository::insert()` + Dompdf PDF
|
||||
2. **Email** — `EmailSendingService::send()` → `VariableResolver::resolve()` → `AttachmentGenerator::generatePdf()` → PHPMailer SMTP
|
||||
|
||||
|
||||
@@ -747,7 +747,7 @@ Indexes: `notifications_unread_created_idx`, `notifications_order_idx`, `notific
|
||||
| `seller_data_json` | JSON | NO | Snapshot of company data at issue time |
|
||||
| `buyer_data_json` | JSON | YES | |
|
||||
| `items_json` | JSON | NO | |
|
||||
| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
|
||||
| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00; Phase 135: new receipts store VAT-aware net sum, no historical backfill |
|
||||
| `total_gross` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
|
||||
| `order_reference_value` | VARCHAR(128) | YES | |
|
||||
| `created_by` | INT UNSIGNED | YES | |
|
||||
@@ -1077,4 +1077,5 @@ Default keys: `cron_run_on_web`, `cron_web_limit`, `gs1_api_login`, `gs1_prefix`
|
||||
- Uses `integrations.name` only for display labels when available.
|
||||
- Filters by selected status groups through `order_status_groups` and `order_statuses`.
|
||||
- Uses existing gross amount columns via `OrdersStatisticsRepository::grossAmountSql()`.
|
||||
- Daily `/statistics/orders` net totals prefer `orders.total_without_tax`, then `orders.total_net`; when source net is missing, Phase 135 computes fallback from `order_items` net/gross values and VAT rates plus delivery net at 23% VAT. Gross `/1.23` remains only for legacy rows without usable items.
|
||||
- No schema migration was introduced for Phase 110.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Technical Changelog
|
||||
|
||||
## 2026-05-16 - Phase 135 Plan 01: Accounting Net Correctness
|
||||
|
||||
**Co zrobiono:**
|
||||
- `ReceiptService::buildItemsSnapshot()` zwraca teraz `total_net` obok `total_gross`, liczac netto per pozycja po realnej stawce VAT z `tax_rate`/`vat`.
|
||||
- `ReceiptService::issue()` zapisuje nowe `receipts.total_net` z obliczonej sumy netto zamiast kopiowac kwote brutto.
|
||||
- Koszt wysylki w paragonach i statystykach pozostaje traktowany jako 23% VAT.
|
||||
- `OrdersStatisticsRepository::netAmountSql()` preferuje `orders.total_without_tax`, potem `orders.total_net`, a przy braku net zrodla liczy fallback z `order_items` (`original_price_without_tax` albo brutto/VAT/ilosc).
|
||||
- Dodano test `ReceiptServiceNetCalculationTest` oraz rozszerzono `OrdersStatisticsRepositoryTest` o source-net precedence i mieszane stawki VAT.
|
||||
|
||||
**Dlaczego:**
|
||||
- Phase 134 potwierdzil aktywne bugi `RECEIPT-NET-FIX` i `STAT-NET`: paragony zapisywaly netto jako kopie brutto, a statystyki zakladaly 23% VAT dla wszystkich zamowien bez source-level net.
|
||||
|
||||
**BREAKING / migracja:**
|
||||
- Brak migracji i brak breaking changes. Operator wybral brak backfillu historycznych paragonow; zmiana dotyczy nowych paragonow i runtime statystyk.
|
||||
|
||||
## 2026-05-16 - Phase 133 Plan 01: Erli Cross-Surface Parity
|
||||
|
||||
**Co zrobiono:**
|
||||
|
||||
@@ -61,7 +61,7 @@ final class ReceiptService
|
||||
$orderReference = $this->resolveOrderReference($config, $order);
|
||||
$sellerSnapshot = $this->buildSellerSnapshot();
|
||||
$buyerSnapshot = $this->buildBuyerSnapshot($addresses);
|
||||
['items' => $itemsSnapshot, 'total_gross' => $totalGross] = $this->buildItemsSnapshot($items, $order);
|
||||
['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->buildItemsSnapshot($items, $order);
|
||||
|
||||
$receiptNumber = $this->receipts->getNextNumber(
|
||||
$configId,
|
||||
@@ -78,7 +78,7 @@ final class ReceiptService
|
||||
'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_net' => number_format($totalNet, 2, '.', ''),
|
||||
'total_gross' => number_format($totalGross, 2, '.', ''),
|
||||
'order_reference_value' => $orderReference,
|
||||
'created_by' => $params['created_by'] ?? null,
|
||||
@@ -250,24 +250,30 @@ final class ReceiptService
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @param array<string, mixed> $order
|
||||
* @return array{items: list<array<string, mixed>>, total_gross: float}
|
||||
* @return array{items: list<array<string, mixed>>, total_gross: float, total_net: float}
|
||||
*/
|
||||
private function buildItemsSnapshot(array $items, array $order): array
|
||||
{
|
||||
$itemsSnapshot = [];
|
||||
$totalGross = 0.0;
|
||||
$totalNet = 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;
|
||||
$priceRaw = $item['original_price_with_tax'] ?? $item['price_gross'] ?? null;
|
||||
$price = $priceRaw !== null ? (float) $priceRaw : 0.0;
|
||||
$vatRaw = $item['tax_rate'] ?? $item['vat'] ?? null;
|
||||
$vat = $vatRaw !== null && $vatRaw !== '' ? (float) $vatRaw : 23.0;
|
||||
$lineGross = $qty * $price;
|
||||
$lineNet = $vat > 0.0 ? round($lineGross / (1 + $vat / 100), 2) : round($lineGross, 2);
|
||||
$totalGross += $lineGross;
|
||||
$totalNet += $lineNet;
|
||||
|
||||
$itemsSnapshot[] = [
|
||||
'name' => $item['original_name'] ?? '',
|
||||
'name' => $item['original_name'] ?? $item['name'] ?? '',
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $lineTotal,
|
||||
'total' => round($lineGross, 2),
|
||||
'vat' => $vat,
|
||||
'sku' => $item['sku'] ?? '',
|
||||
'ean' => $item['ean'] ?? '',
|
||||
@@ -276,13 +282,16 @@ final class ReceiptService
|
||||
|
||||
$deliveryPrice = (float) ($order['delivery_price'] ?? 0);
|
||||
if ($deliveryPrice > 0) {
|
||||
$deliveryVat = 23.0;
|
||||
$deliveryNet = round($deliveryPrice / (1 + $deliveryVat / 100), 2);
|
||||
$totalGross += $deliveryPrice;
|
||||
$totalNet += $deliveryNet;
|
||||
$itemsSnapshot[] = [
|
||||
'name' => 'Koszt wysylki',
|
||||
'quantity' => 1.0,
|
||||
'price' => $deliveryPrice,
|
||||
'total' => $deliveryPrice,
|
||||
'vat' => 23.0,
|
||||
'total' => round($deliveryPrice, 2),
|
||||
'vat' => $deliveryVat,
|
||||
'sku' => '',
|
||||
'ean' => '',
|
||||
];
|
||||
@@ -290,7 +299,8 @@ final class ReceiptService
|
||||
|
||||
return [
|
||||
'items' => $itemsSnapshot,
|
||||
'total_gross' => $totalGross,
|
||||
'total_gross' => round($totalGross, 2),
|
||||
'total_net' => round($totalNet, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ final class OrdersStatisticsRepository
|
||||
private static ?bool $hasOrdersTotalNet = null;
|
||||
private static ?bool $hasOrdersTotalWithTax = null;
|
||||
private static ?bool $hasOrdersTotalGross = null;
|
||||
private static ?bool $hasOrdersDeliveryPrice = null;
|
||||
private static ?bool $hasOrdersIntegrationId = null;
|
||||
private static ?bool $hasOrdersStatusCode = null;
|
||||
private static ?bool $hasOrdersExternalStatusId = null;
|
||||
/** @var array<string, bool> */
|
||||
private static array $hasOrderItemsColumns = [];
|
||||
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
@@ -624,27 +627,121 @@ final class OrdersStatisticsRepository
|
||||
$grossColumn = $orderAlias . '.total_gross';
|
||||
}
|
||||
|
||||
// Fallback: gdy netto z zrodla jest puste (shopPRO nie wysyla netto), wyliczamy z brutto/1.23.
|
||||
// TODO(STAT-NET): docelowo pobierac netto z shopPRO na poziomie zamowienia lub liczyc z order_items po faktycznym tax_rate.
|
||||
$itemNetSql = $this->itemNetAmountSql($orderAlias);
|
||||
$itemWithDeliverySql = $itemNetSql !== null
|
||||
? '((' . $itemNetSql . ') + ' . $this->deliveryNetAmountSql($orderAlias) . ')'
|
||||
: null;
|
||||
$legacyGrossSql = $grossColumn !== null
|
||||
? 'ROUND(COALESCE(' . $grossColumn . ', 0) / 1.23, 2)'
|
||||
: '0';
|
||||
|
||||
if ($netColumn !== null && $grossColumn !== null) {
|
||||
$fallbackSql = $itemWithDeliverySql !== null
|
||||
? 'WHEN ' . $itemWithDeliverySql . ' IS NOT NULL THEN ' . $itemWithDeliverySql . '
|
||||
WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0 THEN ' . $legacyGrossSql
|
||||
: 'WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0 THEN ' . $legacyGrossSql;
|
||||
|
||||
return 'CASE
|
||||
WHEN ' . $netColumn . ' IS NOT NULL AND ' . $netColumn . ' > 0 THEN ' . $netColumn . '
|
||||
WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0 THEN ROUND(' . $grossColumn . ' / 1.23, 2)
|
||||
' . $fallbackSql . '
|
||||
ELSE 0
|
||||
END';
|
||||
}
|
||||
|
||||
if ($netColumn !== null) {
|
||||
if ($itemWithDeliverySql !== null) {
|
||||
return 'CASE
|
||||
WHEN ' . $netColumn . ' IS NOT NULL AND ' . $netColumn . ' > 0 THEN ' . $netColumn . '
|
||||
WHEN ' . $itemWithDeliverySql . ' IS NOT NULL THEN ' . $itemWithDeliverySql . '
|
||||
ELSE 0
|
||||
END';
|
||||
}
|
||||
|
||||
return 'COALESCE(' . $netColumn . ', 0)';
|
||||
}
|
||||
|
||||
if ($grossColumn !== null) {
|
||||
return 'ROUND(COALESCE(' . $grossColumn . ', 0) / 1.23, 2)';
|
||||
if ($itemWithDeliverySql !== null) {
|
||||
return 'CASE
|
||||
WHEN ' . $itemWithDeliverySql . ' IS NOT NULL THEN ' . $itemWithDeliverySql . '
|
||||
WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0 THEN ' . $legacyGrossSql . '
|
||||
ELSE 0
|
||||
END';
|
||||
}
|
||||
|
||||
return $legacyGrossSql;
|
||||
}
|
||||
|
||||
if ($itemWithDeliverySql !== null) {
|
||||
return 'COALESCE(' . $itemWithDeliverySql . ', 0)';
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
|
||||
private function itemNetAmountSql(string $orderAlias): ?string
|
||||
{
|
||||
if (!$this->hasOrderItemsColumn('order_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$netColumn = $this->firstOrderItemColumn(['original_price_without_tax', 'price_net']);
|
||||
$grossColumn = $this->firstOrderItemColumn(['original_price_with_tax', 'price_gross']);
|
||||
if ($netColumn === null && $grossColumn === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$quantitySql = $this->hasOrderItemsColumn('quantity') ? 'COALESCE(oi.quantity, 1)' : '1';
|
||||
$vatColumn = $this->firstOrderItemColumn(['tax_rate', 'vat']);
|
||||
$vatSql = $vatColumn !== null
|
||||
? '(CASE WHEN ' . $vatColumn . ' IS NULL THEN 23.0 ELSE ' . $vatColumn . ' END)'
|
||||
: '23.0';
|
||||
|
||||
$lineCases = [];
|
||||
if ($netColumn !== null) {
|
||||
$lineCases[] = 'WHEN ' . $netColumn . ' IS NOT NULL AND ' . $netColumn . ' > 0
|
||||
THEN ROUND(' . $netColumn . ' * ' . $quantitySql . ', 2)';
|
||||
}
|
||||
if ($grossColumn !== null) {
|
||||
$lineCases[] = 'WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0
|
||||
THEN ROUND((' . $grossColumn . ' * ' . $quantitySql . ') / (1 + (' . $vatSql . ' / 100)), 2)';
|
||||
}
|
||||
|
||||
return 'SELECT SUM(CASE
|
||||
' . implode("\n ", $lineCases) . '
|
||||
ELSE NULL
|
||||
END)
|
||||
FROM order_items oi
|
||||
WHERE oi.order_id = ' . $orderAlias . '.id';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function firstOrderItemColumn(array $columns): ?string
|
||||
{
|
||||
foreach ($columns as $column) {
|
||||
if ($this->hasOrderItemsColumn($column)) {
|
||||
return 'oi.' . $column;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function deliveryNetAmountSql(string $orderAlias): string
|
||||
{
|
||||
if (!$this->hasOrdersColumn('delivery_price')) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return 'CASE
|
||||
WHEN ' . $orderAlias . '.delivery_price IS NOT NULL AND ' . $orderAlias . '.delivery_price > 0
|
||||
THEN ROUND(' . $orderAlias . '.delivery_price / 1.23, 2)
|
||||
ELSE 0
|
||||
END';
|
||||
}
|
||||
|
||||
private function grossAmountSql(string $orderAlias): string
|
||||
{
|
||||
if ($this->hasOrdersColumn('total_with_tax')) {
|
||||
@@ -696,6 +793,9 @@ final class OrdersStatisticsRepository
|
||||
if ($column === 'total_gross' && self::$hasOrdersTotalGross !== null) {
|
||||
return self::$hasOrdersTotalGross;
|
||||
}
|
||||
if ($column === 'delivery_price' && self::$hasOrdersDeliveryPrice !== null) {
|
||||
return self::$hasOrdersDeliveryPrice;
|
||||
}
|
||||
if ($column === 'status_code' && self::$hasOrdersStatusCode !== null) {
|
||||
return self::$hasOrdersStatusCode;
|
||||
}
|
||||
@@ -724,6 +824,9 @@ final class OrdersStatisticsRepository
|
||||
if ($column === 'total_gross') {
|
||||
self::$hasOrdersTotalGross = $exists;
|
||||
}
|
||||
if ($column === 'delivery_price') {
|
||||
self::$hasOrdersDeliveryPrice = $exists;
|
||||
}
|
||||
if ($column === 'status_code') {
|
||||
self::$hasOrdersStatusCode = $exists;
|
||||
}
|
||||
@@ -734,10 +837,32 @@ final class OrdersStatisticsRepository
|
||||
return $exists;
|
||||
}
|
||||
|
||||
private function hasOrderItemsColumn(string $column): bool
|
||||
{
|
||||
if (array_key_exists($column, self::$hasOrderItemsColumns)) {
|
||||
return self::$hasOrderItemsColumns[$column];
|
||||
}
|
||||
|
||||
try {
|
||||
$exists = $this->detectTableColumn('order_items', $column);
|
||||
} catch (Throwable) {
|
||||
$exists = false;
|
||||
}
|
||||
|
||||
self::$hasOrderItemsColumns[$column] = $exists;
|
||||
|
||||
return $exists;
|
||||
}
|
||||
|
||||
private function detectOrdersColumn(string $column): bool
|
||||
{
|
||||
return $this->detectTableColumn('orders', $column);
|
||||
}
|
||||
|
||||
private function detectTableColumn(string $table, string $column): bool
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
$stmt = $this->pdo->query('PRAGMA table_info(orders)');
|
||||
$stmt = $this->pdo->query('PRAGMA table_info(' . $table . ')');
|
||||
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||
if (!is_array($rows)) {
|
||||
return false;
|
||||
@@ -760,7 +885,7 @@ final class OrdersStatisticsRepository
|
||||
AND COLUMN_NAME = :column_name'
|
||||
);
|
||||
$stmt->execute([
|
||||
'table_name' => 'orders',
|
||||
'table_name' => $table,
|
||||
'column_name' => $column,
|
||||
]);
|
||||
|
||||
|
||||
@@ -53,6 +53,44 @@ final class OrdersStatisticsRepositoryTest extends TestCase
|
||||
self::assertSame(123.45, $rows[0]['total_gross']);
|
||||
}
|
||||
|
||||
public function testAggregateByDayUsesSourceNetBeforeItemFallback(): void
|
||||
{
|
||||
$this->seedIntegration(10, 'shoppro', 'Sklep PL');
|
||||
$orderId = $this->seedOrder('shoppro', 10, '2026-05-04 10:00:00', 123.0, 80.0, 12.30);
|
||||
$this->seedOrderItem($orderId, 123.0, null, 1.0, 23.0);
|
||||
|
||||
$rows = $this->repository->aggregateByDay('2026-05-01', '2026-05-31', ['shoppro:10'], []);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame(80.0, $rows[0]['total_net']);
|
||||
}
|
||||
|
||||
public function testAggregateByDayCalculatesNetFromMixedVatItemsAndDelivery(): void
|
||||
{
|
||||
$this->seedIntegration(10, 'shoppro', 'Sklep PL');
|
||||
$orderId = $this->seedOrder('shoppro', 10, '2026-05-05 10:00:00', 366.30, null, 12.30);
|
||||
$this->seedOrderItem($orderId, 123.0, null, 2.0, 23.0);
|
||||
$this->seedOrderItem($orderId, 108.0, null, 1.0, 8.0);
|
||||
|
||||
$rows = $this->repository->aggregateByDay('2026-05-01', '2026-05-31', ['shoppro:10'], []);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertEqualsWithDelta(310.0, $rows[0]['total_net'], 0.001);
|
||||
self::assertSame(366.3, $rows[0]['total_gross']);
|
||||
}
|
||||
|
||||
public function testAggregateByDayUsesGrossFallbackWhenItemsHaveNoUsablePrices(): void
|
||||
{
|
||||
$this->seedIntegration(10, 'shoppro', 'Sklep PL');
|
||||
$orderId = $this->seedOrder('shoppro', 10, '2026-05-06 10:00:00', 123.0);
|
||||
$this->seedOrderItem($orderId, null, null, 1.0, 23.0);
|
||||
|
||||
$rows = $this->repository->aggregateByDay('2026-05-01', '2026-05-31', ['shoppro:10'], []);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame(100.0, $rows[0]['total_net']);
|
||||
}
|
||||
|
||||
public function testAggregateByMonthCountsErliOrders(): void
|
||||
{
|
||||
$this->seedIntegration(20, 'erli', 'Erli');
|
||||
@@ -80,10 +118,23 @@ final class OrdersStatisticsRepositoryTest extends TestCase
|
||||
source_updated_at TEXT,
|
||||
fetched_at TEXT,
|
||||
status_code TEXT,
|
||||
total_without_tax REAL,
|
||||
delivery_price REAL,
|
||||
total_with_tax REAL
|
||||
)'
|
||||
);
|
||||
|
||||
$this->pdo->exec(
|
||||
'CREATE TABLE order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER,
|
||||
original_price_with_tax REAL,
|
||||
original_price_without_tax REAL,
|
||||
quantity REAL,
|
||||
tax_rate REAL
|
||||
)'
|
||||
);
|
||||
|
||||
$this->pdo->exec(
|
||||
'CREATE TABLE integrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -112,11 +163,24 @@ final class OrdersStatisticsRepositoryTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedOrder(string $source, int $integrationId, string $orderedAt, float $gross): void
|
||||
private function seedOrder(
|
||||
string $source,
|
||||
int $integrationId,
|
||||
string $orderedAt,
|
||||
float $gross,
|
||||
?float $net = null,
|
||||
float $deliveryPrice = 0.0
|
||||
): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO orders (source, integration_id, ordered_at, fetched_at, status_code, total_with_tax)
|
||||
VALUES (:source, :integration_id, :ordered_at, :fetched_at, :status_code, :total_with_tax)'
|
||||
'INSERT INTO orders (
|
||||
source, integration_id, ordered_at, fetched_at, status_code,
|
||||
total_without_tax, delivery_price, total_with_tax
|
||||
)
|
||||
VALUES (
|
||||
:source, :integration_id, :ordered_at, :fetched_at, :status_code,
|
||||
:total_without_tax, :delivery_price, :total_with_tax
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'source' => $source,
|
||||
@@ -124,8 +188,35 @@ final class OrdersStatisticsRepositoryTest extends TestCase
|
||||
'ordered_at' => $orderedAt,
|
||||
'fetched_at' => $orderedAt,
|
||||
'status_code' => 'new',
|
||||
'total_without_tax' => $net,
|
||||
'delivery_price' => $deliveryPrice,
|
||||
'total_with_tax' => $gross,
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
private function seedOrderItem(
|
||||
int $orderId,
|
||||
?float $grossPrice,
|
||||
?float $netPrice,
|
||||
float $quantity,
|
||||
?float $taxRate
|
||||
): void {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO order_items (
|
||||
order_id, original_price_with_tax, original_price_without_tax, quantity, tax_rate
|
||||
) VALUES (
|
||||
:order_id, :original_price_with_tax, :original_price_without_tax, :quantity, :tax_rate
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'order_id' => $orderId,
|
||||
'original_price_with_tax' => $grossPrice,
|
||||
'original_price_without_tax' => $netPrice,
|
||||
'quantity' => $quantity,
|
||||
'tax_rate' => $taxRate,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resetColumnCache(): void
|
||||
@@ -135,6 +226,7 @@ final class OrdersStatisticsRepositoryTest extends TestCase
|
||||
'hasOrdersTotalNet',
|
||||
'hasOrdersTotalWithTax',
|
||||
'hasOrdersTotalGross',
|
||||
'hasOrdersDeliveryPrice',
|
||||
'hasOrdersIntegrationId',
|
||||
'hasOrdersStatusCode',
|
||||
'hasOrdersExternalStatusId',
|
||||
@@ -143,5 +235,9 @@ final class OrdersStatisticsRepositoryTest extends TestCase
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
|
||||
$property = new ReflectionProperty(OrdersStatisticsRepository::class, 'hasOrderItemsColumns');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue(null, []);
|
||||
}
|
||||
}
|
||||
|
||||
52
tests/Unit/ReceiptServiceNetCalculationTest.php
Normal file
52
tests/Unit/ReceiptServiceNetCalculationTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Modules\Accounting\ReceiptService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
|
||||
final class ReceiptServiceNetCalculationTest extends TestCase
|
||||
{
|
||||
public function testBuildItemsSnapshotCalculatesVatAwareNetTotal(): void
|
||||
{
|
||||
$snapshot = $this->buildItemsSnapshot([
|
||||
[
|
||||
'original_name' => 'Produkt 23',
|
||||
'quantity' => 2,
|
||||
'original_price_with_tax' => 123.0,
|
||||
'tax_rate' => 23.0,
|
||||
],
|
||||
[
|
||||
'original_name' => 'Produkt 8',
|
||||
'quantity' => 1,
|
||||
'original_price_with_tax' => 108.0,
|
||||
'tax_rate' => 8.0,
|
||||
],
|
||||
], [
|
||||
'delivery_price' => 12.30,
|
||||
]);
|
||||
|
||||
self::assertSame(366.30, $snapshot['total_gross']);
|
||||
self::assertSame(310.00, $snapshot['total_net']);
|
||||
self::assertCount(3, $snapshot['items']);
|
||||
self::assertSame('Koszt wysylki', $snapshot['items'][2]['name']);
|
||||
self::assertSame(23.0, $snapshot['items'][2]['vat']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @param array<string, mixed> $order
|
||||
* @return array{items:list<array<string,mixed>>,total_gross:float,total_net:float}
|
||||
*/
|
||||
private function buildItemsSnapshot(array $items, array $order): array
|
||||
{
|
||||
$service = (new ReflectionClass(ReceiptService::class))->newInstanceWithoutConstructor();
|
||||
$method = new ReflectionMethod(ReceiptService::class, 'buildItemsSnapshot');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($service, $items, $order);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user