This commit is contained in:
2026-05-17 20:54:50 +02:00
parent 9f2b5e5f3b
commit ff91a29e4f
22 changed files with 1060 additions and 468 deletions

View File

@@ -21,7 +21,7 @@ Progress: 5 of 9 phases complete (56%).
| 136 | Fakturownia Invoice Idempotency | 1/1 | Complete (2026-05-17; Fakturownia oid idempotency, migration/PHPUnit/Sonar env gaps documented) |
| 137 | Delivery Status Backlog Verification | 1/1 | Complete (2026-05-17; verification-only closure, no stale automation keys found) |
| 138 | Security and Legacy Hardening | 1/1 | Complete (2026-05-17; SMTP TLS/template/session/view hardening, PHPUnit/Sonar env gaps documented) |
| 139 | Sonar Critical/Major Cleanup | TBD | Ready to plan |
| 139 | Sonar Critical/Major Cleanup | 1/TBD | Active (139-01 implemented 2026-05-17; total Sonar BLOCKER/CRITICAL/MAJOR 648 -> 605) |
| 140 | Performance Safeguards | TBD | Not started |
| 141 | God Classes and Duplication Refactor | TBD | Not started |
| 142 | Architecture Guardrails | TBD | Not started |
@@ -54,7 +54,7 @@ Plans: 138-01 (complete; `.paul/phases/138-security-and-legacy-hardening/138-01-
### Phase 139: Sonar Critical/Major Cleanup
Focus: Zmniejszyc potwierdzone problemy SonarQube z `concerns.md`: generic exceptions, zbyt wiele returnow, powtarzajace sie literaly, cognitive complexity, unused parameters, use-namespace-import oraz accessibility (`aria-label`, `<output>`). Przed kazda grupa zmian odswiezyc stan skanu albo lokalnie potwierdzic wystepowanie problemu.
Plans: TBD (defined during $paul-plan)
Plans: 139-01 (implemented; `.paul/phases/139-sonar-critical-major-cleanup/139-01-SUMMARY.md`)
### Phase 140: Performance Safeguards
@@ -633,4 +633,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-05-17 - Phase 138 closed; Phase 139 ready to plan*
*Last updated: 2026-05-17 - Phase 139 plan 139-01 implemented*

View File

@@ -5,34 +5,34 @@
See: .paul/PROJECT.md (updated 2026-05-17)
**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 138 Security and Legacy Hardening complete, Phase 139 Sonar Critical/Major Cleanup ready to plan.
**Current focus:** v3.9 Stabilizacja i splata dlugu technicznego; Phase 139 Sonar Critical/Major Cleanup plan 139-01 implemented, ready for UNIFY/next slice.
## Current Position
Milestone: v3.9 Stabilizacja i splata dlugu technicznego
Phase: 139 of 142 (Sonar Critical/Major Cleanup) - Ready to plan
Plan: Not started
Status: Ready for next PLAN
Last activity: 2026-05-17 18:35 - Phase 138 complete, transitioned to Phase 139
Phase: 139 of 142 (Sonar Critical/Major Cleanup) - Applying
Plan: 139-01 implemented
Status: APPLY complete; final Sonar scan processed
Last activity: 2026-05-17 19:58 - final Sonar scan processed; selected delivery-status files clean, statistics file has only remaining `php:S1448`
Progress:
- Milestone v3.9: [######----] 56% (5 of 9 phases complete)
- Phase 139: [----------] 0% (not started)
- Phase 139: [####------] 40% (fresh baseline + first cleanup slice complete)
## Loop Position
Current loop state:
```
PLAN -> APPLY -> UNIFY
done done done [Loop complete - ready for next PLAN]
done done open [ready for summary/UNIFY]
```
## Session Continuity
Last session: 2026-05-17 18:35
Stopped at: Phase 138 complete
Next action: Run $paul-plan for Phase 139 (Sonar Critical/Major Cleanup)
Resume file: .paul/phases/138-security-and-legacy-hardening/138-01-SUMMARY.md
Last session: 2026-05-17 19:58
Stopped at: Plan 139-01 implemented; documentation and summary pending finalization
Next action: Complete Phase 139-01 UNIFY/summary, then plan the next Sonar slice
Resume file: .paul/phases/139-sonar-critical-major-cleanup/139-01-PLAN.md
## Pending parallel work
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
@@ -112,6 +112,8 @@ Branch: main
- Phase 138 hardened SMTP mailbox tests: TLS certificate and peer-name verification are strict by default; `SMTP_ALLOW_SELF_SIGNED_DEV=true` works only in local/dev/development/testing.
- Phase 138 blocks newly saved e-mail/SMS templates that contain unknown `{{group.variable}}` placeholders via the shared `TemplateVariableCatalog`.
- Phase 138 centralized raw `$_SESSION` access in `Session` and replaced targeted hard view `require`/inline `\App\...` patterns.
- Phase 139 is confirmed by operator. Plan 139-01 must run a fresh `sonar-scanner` before code cleanup; stale API-only results are not enough. Scope should fix as many confirmed issues as safely possible, split across multiple plans if needed.
- Phase 139-01 fresh scan found 648 OPEN BLOCKER/CRITICAL/MAJOR issues; final scan after cleanup found 605. Delivery status target files are clean; `OrdersStatisticsRepository` still needs a class split for `php:S1448`.
### Blockers / Concerns
@@ -120,6 +122,7 @@ Branch: main
- Phase 136: Fakturownia idempotency strategy implemented and UNIFY complete; runtime migration still needs local MySQL online.
- Phase 136 APPLY: `php bin/migrate.php` could not run because local MySQL refused connection; `vendor/bin/phpunit` is missing; `sonar-scanner` is unavailable in PATH. PHP lint, documentation grep, git diff check and ad-hoc SQLite repository smoke passed.
- Phase 138 APPLY: `vendor/bin/phpunit` is missing, so new unit tests were linted but not run; `sonar-scanner` is unavailable in PATH. PHP lint, targeted `rg` checks and `git diff --check` passed.
- Phase 139 APPLY: local PATH still does not contain `sonar-scanner`, but the official Windows x64 scanner was downloaded to `%TEMP%` and used successfully. `vendor/bin/phpunit` remains unavailable because `vendor/` is missing and Composer is not installed in PATH.
- Phase 140: deferred indexes should be applied only after operator confirms dataset size/prod timing.
### Deferred Issues
@@ -129,7 +132,8 @@ Branch: main
## Pending Actions
- Phase 138 follow-up: run `vendor/bin/phpunit tests/Unit/SmtpSecurityContextFactoryTest.php tests/Unit/TemplateVariableCatalogTest.php` after dependencies are installed.
- Phase 138 follow-up: run SonarQube scan after `sonar-scanner` is installed or added to PATH.
- Phase 139 follow-up: split `OrdersStatisticsRepository` (`php:S1448`, 43 methods) or include it in Phase 141 god-class refactor.
- Phase 139 follow-up: continue with fresh confirmed groups `php:S1142`, `php:S1192`, `php:S4833`, `php:S3776`, `php:S1172`, `php:S112`, plus Web table/accessibility issues.
- Phase 138 manual smoke: test a real SMTP SSL/STARTTLS mailbox in strict mode; test invalid and valid e-mail/SMS template saves in UI.
- Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online).
- Backfill zamowienia #882 - operator robi recznie po wdrozeniu (poza zakresem planu).
@@ -189,4 +193,4 @@ Branch: main
## 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, Phase 134, Phase 135, Phase 136 and Phase 138 gaps documented because CLI was not available in PATH.
- `sonar-scanner` required after APPLY; Phase 139 used the official Windows x64 scanner from `%TEMP%` because the CLI is still not available in PATH. Earlier Phase 116, 117, 121, 122, 128, 129, 130, 131, 132, 133, 134, 135, 136 and 138 gaps remain historical.

View File

@@ -7,7 +7,7 @@ Szczegoly i dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
| Group / item | Status po audycie | Krotki wniosek |
|--------------|-------------------|----------------|
| God Classes | **Active** | Klasy nadal sa duze; stare LOC/method counts sa nieaktualne, ale Phase 141 pozostaje zasadny. |
| SonarQube Issues | **Stale baseline / active patterns** | Liczby wymagaja nowego skanu; lokalnie nadal widac wzorce do sprzatania. `RuntimeException` import jest juz naprawiony. |
| SonarQube Issues | **Fresh baseline / active patterns** | Phase 139 odswiezyl baseline i zredukowal pierwsza fale issue; pozostale grupy sa aktualne po skanie z 2026-05-17. |
| Breaking: delivery status group keys | **Closed in Phase 137** | DB-driven statusy sa wdrozone, a read-only DB check nie znalazl starych ani niepoprawnych kluczy automatyzacji. |
| 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. |
@@ -29,6 +29,7 @@ Szczegoly i dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
| `src/Modules/Orders/OrdersController.php` | 1,187 | 22 | UI + AJAX + list + detail + search combined; S1448 |
| `src/Modules/Automation/AutomationService.php` | 834 | 24 | All action handlers in one class; S1448 |
| `src/Modules/Settings/ShopproOrderMapper.php` | 867 | 25 | 25+ transformation methods; S1448 |
| `src/Modules/Statistics/OrdersStatisticsRepository.php` | 901 | 43 | Reporting SQL, schema detection and row mapping combined; S1448 remains after Phase 139-01 |
| `src/Modules/Settings/ApaczkaShipmentService.php` | 1,044 | — | API payload deeply nested |
| `src/Modules/Settings/ShopproIntegrationsController.php` | 1,044 | — | OAuth + mapping + sync combined |
@@ -36,23 +37,22 @@ Szczegoly i dowody: `.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md`.
## SonarQube Issues (new code since 2026-03-28)
**Total: 174 issues** (BLOCKER=1, CRITICAL=47, MAJOR=110, MINOR=16)
Fresh Phase 139 baseline after plan 139-01: **605 OPEN BLOCKER/CRITICAL/MAJOR issues** (BLOCKER=0, CRITICAL=181, MAJOR=424).
| Rule | Count | Severity | Examples |
|------|-------|----------|---------|
| `php:S112`Generic exceptions | 95+ | MAJOR | `throw new \Exception(...)` everywhere; use domain exceptions |
| `php:S1142`Excess return statements | 57+ | MAJOR | Many service methods have 4-10 returns |
| `php:S1192`Duplicated string literals | 39 | CRITICAL | Route paths, status strings, HTTP headers |
| `php:S3776` — Cognitive complexity > 15 | 9+ | CRITICAL | `ShopproOrderMapper::initOrderFromArray()` (28), `ShipmentTrackingHandler` (27) |
| `php:S1448`Class too large | 6+ | MAJOR | See god classes above |
| `php:S1172`Unused parameters | 11+ | MAJOR | `$request` params in handlers, unused payload params |
| `php:S1142`Excess return statements | 148 | MAJOR | Many service/controller methods still have 4+ returns |
| `php:S1192`Duplicated string literals | 101 | CRITICAL | Route paths, SQL fragments, status strings, HTTP headers |
| `php:S4833`Use namespace import / direct include patterns | 93 | MAJOR | Remaining source/view import/include cleanup outside Phase 139-01 target |
| `php:S3776` — Cognitive complexity > 15 | 54 | CRITICAL | Mapper/service/reporting methods needing focused refactor |
| `php:S1172`Unused parameters | 41 | MAJOR | Handler payload/request params |
| `php:S112`Generic exceptions | 40 | MAJOR | Remaining generic exceptions outside delivery-status repository |
| `php:S1448` — Class too large | 16 | MAJOR | See god classes above |
| `php:S4423` — Weak TLS protocol | stale | **CRITICAL** | Resolved in Phase 138: `EmailMailboxController::testConnection()` uzywa strict SSL context i STARTTLS |
| `php:S5911` — Missing import | 1 | **BLOCKER** | `AllegroOrderImportService``RuntimeException` not imported |
| `php:S4833` — Use namespace import | 2 | MAJOR | `resources/views/accounting/index.php:31`, `orders/show.php:780` |
| `Web:S6827` — Anchors without accessible text | 9+ | MINOR | Icon-only buttons need `aria-label` |
| `Web:S6819` — Accessibility | 7+ | MAJOR | Use `<output>` instead of `<span role="status">` |
| `Web:TableHeaderHasIdOrScopeCheck` | 16 | MAJOR | Tables without explicit header scope/id |
| `Web:S6819` — Accessibility | 5 | MAJOR | Use semantic output/status elements where applicable |
**Note:** SonarQube scan not run for phases 105108 — baseline may be stale.
Phase 139-01 reduced the fresh total by 43 issues and cleared all selected delivery-status files. Remaining detailed baseline: `.paul/phases/139-sonar-critical-major-cleanup/SONAR-BASELINE.md`.
## Breaking Changes

View File

@@ -1,5 +1,19 @@
# Technical Changelog
## 2026-05-17 - Phase 139 Plan 01: Sonar Critical/Major Cleanup
**Co zrobiono:**
- Swiezy Sonar baseline: 648 OPEN BLOCKER/CRITICAL/MAJOR przed cleanupem; finalnie 605 po zmianach.
- Delivery status cluster wyczyszczony do 0 issue w targetowanych plikach: domenowy `DeliveryStatusException`, guard helpers w repozytorium, tabelaryczne mapowania/URL-e w `DeliveryStatus`, kontrolery bez duplikowanych redirectow i widoki bez bezposrednich include.
- `OrdersStatisticsRepository` ma uproszczone cache kolumn i generowanie SQL kwot/daty/IN/ROUND; usunieto potwierdzone issue z nadmiarowymi returnami, zlozonoscia i duplikatami literalow w targetowanym zakresie.
- Pozostawiono `php:S1448` w `OrdersStatisticsRepository` jako nastepny refactor-slice, bo wymaga rozdzielenia klasy.
**Dlaczego:**
- Phase 139 ma pracowac na aktualnych wynikach SonarQube. Pierwsza fala wybiera bezpieczne refaktory bez zmiany zachowania biznesowego.
**BREAKING / migracja:**
- Brak migracji DB i brak zmian breaking.
## 2026-05-17 - Phase 138 Plan 01: Security and Legacy Hardening
**Co zrobiono:**

View File

@@ -0,0 +1,239 @@
---
phase: 139-sonar-critical-major-cleanup
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- .paul/phases/139-sonar-critical-major-cleanup/SONAR-BASELINE.md
- DOCS/todo.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
- .paul/codebase/concerns.md
- .paul/codebase/tech_changelog.md
- src/Core/Exceptions/DeliveryStatusException.php
- src/Modules/Shipments/DeliveryStatusRepository.php
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Settings/DeliveryStatusesController.php
- src/Modules/Settings/DeliveryStatusMappingController.php
- resources/views/settings/delivery-statuses.php
- resources/views/settings/delivery-status-mappings.php
- resources/views/settings/_delivery-status-mappings-content.php
- src/Modules/Statistics/OrdersStatisticsRepository.php
- tests/Unit/DeliveryStatusTest.php
- tests/Unit/OrdersStatisticsRepositoryTest.php
autonomous: false
delegation: off
---
<objective>
## Goal
Refresh the SonarQube baseline and remove the largest safe batch of confirmed BLOCKER/CRITICAL/MAJOR issues in Phase 139, starting with the Delivery Status/settings/views cluster and the Statistics repository cluster.
## Purpose
Phase 134 proved that the stored Sonar numbers are stale, and Phase 138 already fixed at least one issue that the current Sonar API can still report from an old scan. This plan prevents work from being driven by stale false positives, then cleans a broad but coherent set of current issues without changing business behavior.
## Output
A fresh Sonar baseline file, updated debt documentation, and a first code cleanup slice that reduces confirmed Sonar issues for generic exceptions, duplicated literals, excessive returns, cognitive complexity, namespace/import complaints, and accessibility where those issues are still present after the fresh scan.
</objective>
<context>
<clarifications>
- **Faza** - Czy planujemy Phase 139 jako nastepna faze?
-> Odpowiedz: Tak.
- **Skan** - Czy pierwszy plan ma zaczac od swiezego `sonar-scanner` jako warunku?
-> Odpowiedz: Tak, zrobmy swiezy skan.
- **Zakres** - Jaki zakres ma miec Phase 139?
-> Odpowiedz: Poprawiamy wszystko, moze nie na raz; poprawic jak najwiecej rzeczy.
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@AGENTS.md
@DOCS/ARCHITECTURE.md
@DOCS/DB_SCHEMA.md
@DOCS/TECH_CHANGELOG.md
## Prior Work
@.paul/phases/134-backlog-reality-check/BACKLOG-AUDIT.md
@.paul/phases/138-security-and-legacy-hardening/138-01-SUMMARY.md
## Source Files
@sonar-project.properties
@.paul/SPECIAL-FLOWS.md
@.paul/codebase/concerns.md
@src/Modules/Shipments/DeliveryStatusRepository.php
@src/Modules/Shipments/DeliveryStatus.php
@src/Modules/Settings/DeliveryStatusesController.php
@src/Modules/Settings/DeliveryStatusMappingController.php
@resources/views/settings/delivery-statuses.php
@resources/views/settings/delivery-status-mappings.php
@resources/views/settings/_delivery-status-mappings-content.php
@src/Modules/Statistics/OrdersStatisticsRepository.php
@tests/Unit/DeliveryStatusTest.php
@tests/Unit/OrdersStatisticsRepositoryTest.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| `sonar-scanner` | required | Before cleanup and again after APPLY | o |
| /code-review | optional | Before UNIFY if the cleanup touches shared repositories/controllers | o |
**BLOCKING:** This plan requires a fresh `sonar-scanner` run before code cleanup. Current local check showed `sonar-scanner` is not in PATH, so APPLY must stop at the checkpoint until the scanner is available or an absolute scanner command is provided.
</skills>
<acceptance_criteria>
## AC-1: Fresh Sonar baseline is authoritative
```gherkin
Given Phase 139 starts with stale Sonar counts in `.paul/codebase/concerns.md`
When APPLY begins
Then `sonar-scanner` runs successfully and the resulting current BLOCKER/CRITICAL/MAJOR issues are captured in `.paul/phases/139-sonar-critical-major-cleanup/SONAR-BASELINE.md`
```
## AC-2: Stale issues are not fixed blindly
```gherkin
Given the fresh scan no longer reports an issue from the old concerns list
When the cleanup target list is selected
Then that issue is marked stale/resolved in the baseline notes instead of forcing an unrelated code change
```
## AC-3: Delivery status cluster is cleaned where confirmed
```gherkin
Given the fresh scan confirms issues in delivery status repository/controller/view files
When the cleanup completes
Then confirmed `php:S112`, `php:S1192`, `php:S1142`, `php:S1066`, `php:S4833`, and relevant Web accessibility issues in that cluster are removed or explicitly documented as no longer present
```
## AC-4: Statistics repository cluster is cleaned where confirmed
```gherkin
Given the fresh scan confirms issues in `OrdersStatisticsRepository`
When the cleanup completes
Then confirmed excessive returns, cognitive complexity, and duplicated literal issues in the targeted statistics methods are reduced without changing aggregation results
```
## AC-5: Behavior remains unchanged
```gherkin
Given existing delivery status UI and statistics tests cover current behavior
When the refactor is complete
Then existing tests still pass or remain blocked only by the known missing `vendor/bin/phpunit` environment gap, and targeted smoke checks show the same user-visible behavior
```
## AC-6: Documentation and next slices are clear
```gherkin
Given Phase 139 will likely require more than one plan
When this plan completes
Then docs record the refreshed baseline, fixed rule groups, remaining issue groups, and a recommended next Phase 139 plan slice
```
</acceptance_criteria>
<tasks>
<task type="checkpoint:human-action" gate="blocking">
<what-built>No code should be changed before the current Sonar baseline is refreshed.</what-built>
<how-to-verify>
1. Ensure `sonar-scanner` is installed and available in PATH, or provide the absolute scanner command.
2. Run: `sonar-scanner --version`
3. Continue only when the command succeeds in the project directory.
</how-to-verify>
<resume-signal>Type "scanner ready" after PATH is fixed, or provide the absolute scanner command to use.</resume-signal>
</task>
<task type="auto">
<name>Task 1: Refresh Sonar baseline and select confirmed targets</name>
<files>.paul/phases/139-sonar-critical-major-cleanup/SONAR-BASELINE.md, DOCS/todo.md, .paul/codebase/concerns.md</files>
<action>
Run a fresh Sonar scan before code cleanup.
- Run `sonar-scanner --version`, then `sonar-scanner` from the project root.
- After the scan completes, query SonarQube for OPEN issues on `orderPRO` with severities `BLOCKER,CRITICAL,MAJOR`.
- Record totals by severity and rule, the scan timestamp, and the exact issues selected for this plan in `SONAR-BASELINE.md`.
- Follow `.paul/SPECIAL-FLOWS.md`: if `DOCS/todo.md` does not exist, create it with a `## SonarQube - YYYY-MM-DD` heading and add only the current new/remaining grouped issues. Also update `.paul/codebase/concerns.md` with refreshed counts.
- Compare the fresh scan with Phase 138 changes. If old issues such as SMTP TLS still appear only in pre-refresh data, mark them stale instead of modifying code.
- Confirm that the first implementation slice remains the Delivery Status cluster plus the Statistics repository cluster. If the fresh scan shows those clusters disappeared, select the largest equivalent confirmed cluster and document the deviation before editing code.
</action>
<verify>`sonar-scanner` exits successfully; SonarQube API returns current issues; `rg -n "Phase 139|SonarQube|php:S" .paul/phases/139-sonar-critical-major-cleanup/SONAR-BASELINE.md .paul/codebase/concerns.md DOCS/todo.md`</verify>
<done>AC-1 and AC-2 satisfied; cleanup targets are based on a fresh scan, not stale concerns.</done>
</task>
<task type="auto">
<name>Task 2: Clean confirmed Delivery Status and settings-view issues</name>
<files>src/Core/Exceptions/DeliveryStatusException.php, src/Modules/Shipments/DeliveryStatusRepository.php, src/Modules/Shipments/DeliveryStatus.php, src/Modules/Settings/DeliveryStatusesController.php, src/Modules/Settings/DeliveryStatusMappingController.php, resources/views/settings/delivery-statuses.php, resources/views/settings/delivery-status-mappings.php, resources/views/settings/_delivery-status-mappings-content.php, tests/Unit/DeliveryStatusTest.php</files>
<action>
Clean only issues confirmed by the fresh scan or by direct local code inspection.
- Add a typed `DeliveryStatusException` extending `OrderProException`, and replace generic `RuntimeException` throws in `DeliveryStatusRepository` with this domain exception.
- In `DeliveryStatusRepository`, extract small guard/query helpers for duplicate-key, not-found, system-status, and in-use checks so methods stay readable and single-purpose.
- In `DeliveryStatusesController` and `DeliveryStatusMappingController`, remove repeated literals such as redirect provider fragments and repeated Polish error prefixes through narrow constants/helpers.
- Flatten any nested `if` that Sonar confirms in `DeliveryStatusesController::validateFields()` without changing validation messages.
- Replace non-alert hard includes of `_delivery-status-mappings-content.php` with the established `$component()` helper and explicit params.
- If the fresh scan still reports Web accessibility issues in this cluster, replace status-role spans with `<output>` or add missing accessible names where applicable. Do not change visual layout beyond semantic markup.
- Do not modify delivery-status schema, provider mappings, default status semantics, or alert-component includes that Phase 120 explicitly accepts.
</action>
<verify>`C:\xampp\php\php.exe -l src/Core/Exceptions/DeliveryStatusException.php`; `C:\xampp\php\php.exe -l` on all touched delivery status PHP files/views; `vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php` if dependencies exist; targeted Sonar API check for touched files after final scan</verify>
<done>AC-3 and AC-5 satisfied for the delivery status cluster.</done>
</task>
<task type="auto">
<name>Task 3: Reduce confirmed Statistics repository complexity/literal issues</name>
<files>src/Modules/Statistics/OrdersStatisticsRepository.php, tests/Unit/OrdersStatisticsRepositoryTest.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md, .paul/codebase/tech_changelog.md</files>
<action>
Refactor `OrdersStatisticsRepository` only around issues confirmed by the fresh scan.
- Introduce narrow constants/helpers for repeated date suffixes, common SQL fragments, or channel/source literals where they improve readability and reduce confirmed `php:S1192`.
- Reduce return counts in confirmed methods by extracting helpers such as source-net column selection, gross column selection, item fallback SQL building, and raw status SQL selection.
- Reduce `netAmountSql()` cognitive complexity by moving independent decisions into small private methods with descriptive names.
- Preserve the Phase 135 net calculation contract: source-level net first, item VAT fallback second, legacy gross `/1.23` only as last fallback, and delivery net at 23% when source net is missing.
- Extend or adjust `OrdersStatisticsRepositoryTest` only where needed to lock the current SQL behavior before and after the refactor.
- Update docs/changelog with the refreshed Sonar cleanup and list remaining Phase 139 groups for the next plan.
</action>
<verify>`C:\xampp\php\php.exe -l src/Modules/Statistics/OrdersStatisticsRepository.php`; `vendor/bin/phpunit tests/Unit/OrdersStatisticsRepositoryTest.php` if dependencies exist; existing ad-hoc SQLite tests still pass if PHPUnit is unavailable; `git diff --check`; final `sonar-scanner` or documented scanner/tooling gap only after the initial fresh scan succeeded</verify>
<done>AC-4, AC-5, and AC-6 satisfied for the statistics cluster and documentation.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Do not edit runtime code before a fresh `sonar-scanner` baseline is available.
- Do not use stale Sonar API results from before the fresh scan as the sole reason for a code change.
- Do not change database schema or add migrations in this plan.
- Do not use `DB_HOST_REMOTE`; this plan does not need DB writes or migrations.
- Do not alter order import, shipment creation, delivery status mapping semantics, or statistics query outputs beyond behavior-preserving refactors.
- Do not add native `alert()` / `confirm()` calls or CSS in views.
## SCOPE LIMITS
- This is the first Phase 139 cleanup slice, not the whole phase.
- God-class splitting (`php:S1448`) belongs to Phase 141 unless the fresh scan shows a tiny, local class-boundary fix that does not expand this plan.
- Broad UI/accessibility cleanup outside the selected delivery-status views is deferred to later Phase 139 plans.
- If `vendor/bin/phpunit` is still missing, run PHP lint and available SQLite/ad-hoc checks, then document the PHPUnit gap.
</boundaries>
<verification>
Before declaring plan complete:
- [x] `sonar-scanner --version` and `sonar-scanner` succeed before code cleanup.
- [x] `.paul/phases/139-sonar-critical-major-cleanup/SONAR-BASELINE.md` records current Sonar totals and selected issue list.
- [x] `C:\xampp\php\php.exe -l` passes for every touched PHP source and view file.
- [x] `vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php tests/Unit/OrdersStatisticsRepositoryTest.php` passes, or missing PHPUnit is documented.
- [x] Final `sonar-scanner` is attempted after implementation; remaining issues in touched files are documented.
- [x] `git diff --check` passes.
- [x] `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`, `.paul/codebase/concerns.md`, and `.paul/codebase/tech_changelog.md` reflect the cleanup and remaining Phase 139 work.
</verification>
<success_criteria>
- Fresh Sonar data replaces the stale Phase 134 counts for Phase 139 planning.
- No stale false positive is fixed blindly.
- Confirmed Delivery Status/settings/view issues selected for this plan are removed or documented as stale after refresh.
- Confirmed Statistics repository issues selected for this plan are reduced without changing report semantics.
- Tests/lints pass or environment gaps are explicitly documented.
- The summary recommends the next Phase 139 slice for remaining confirmed CRITICAL/MAJOR issues.
</success_criteria>
<output>
After completion, create `.paul/phases/139-sonar-critical-major-cleanup/139-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,62 @@
# Phase 139 Plan 01 Summary - Sonar Critical/Major Cleanup
Date: 2026-05-17
## Result
Plan 139-01 refreshed the Sonar baseline and completed the first cleanup slice.
Fresh baseline before code changes:
- Total OPEN `BLOCKER,CRITICAL,MAJOR`: 648
- BLOCKER=0, CRITICAL=200, MAJOR=448
Final scan after cleanup:
- Total OPEN `BLOCKER,CRITICAL,MAJOR`: 605
- BLOCKER=0, CRITICAL=181, MAJOR=424
- Delta: -43 issues
## Implemented
- Added `App\Core\Exceptions\DeliveryStatusException`.
- Replaced generic delivery-status repository exceptions with domain exceptions and small guard/count helpers.
- Refactored `DeliveryStatus` provider normalization, descriptions, fallback guessing and tracking URLs to table-driven helpers.
- Cleaned delivery-status settings controllers: duplicated redirect/error fragments, unused request parameter and excessive returns.
- Replaced direct delivery-status view includes with the existing `$component()` helper.
- Simplified `OrdersStatisticsRepository` column cache and net/gross SQL generation while preserving Phase 135 net calculation semantics.
- Updated `OrdersStatisticsRepositoryTest` cache reset for the new column-cache map.
- Added current Sonar baseline and follow-up docs.
## Target File Status
All selected delivery-status files are clean for OPEN `BLOCKER,CRITICAL,MAJOR` Sonar issues:
- `src/Modules/Shipments/DeliveryStatusRepository.php`
- `src/Modules/Shipments/DeliveryStatus.php`
- `src/Modules/Settings/DeliveryStatusesController.php`
- `src/Modules/Settings/DeliveryStatusMappingController.php`
- `resources/views/settings/delivery-statuses.php`
- `resources/views/settings/delivery-status-mappings.php`
- `src/Core/Exceptions/DeliveryStatusException.php`
Remaining selected statistics issue:
- `src/Modules/Statistics/OrdersStatisticsRepository.php`: `php:S1448`, 43 methods. Needs a class split.
## Verification
Passed:
- `php -l` on all touched PHP source and view files.
- Ad-hoc `DeliveryStatus` runtime smoke through `tests/bootstrap.php`.
- Ad-hoc SQLite in-memory `OrdersStatisticsRepository` runtime smoke covering source-net precedence, mixed VAT item fallback, delivery net and Erli channel aggregation.
- Final `sonar-scanner` completed and SonarQube processing ended with `SUCCESS`.
Blocked:
- `vendor/bin/phpunit` could not run because `vendor/` is missing and Composer is not installed in PATH.
## Next Slice
Recommended next Phase 139 plan:
1. Split `OrdersStatisticsRepository` or defer it to Phase 141 god-class refactor.
2. Continue with confirmed high-count rules: `php:S1142`, `php:S1192`, `php:S4833`, `php:S3776`, `php:S1172`, `php:S112`.
3. Handle Web table/accessibility issues as a separate UI-safe slice.

View File

@@ -0,0 +1,84 @@
# Phase 139 Sonar Baseline
Date: 2026-05-17
Project: `orderPRO`
Dashboard: `https://sonar.project-pro.pl/dashboard?id=orderPRO`
## Fresh Baseline Before Cleanup
Fresh scanner run completed successfully with the Windows x64 SonarScanner CLI from a temporary local install.
Analysis id: `ecab5fe5-582e-4935-9cd2-bf798b10afb1`
OPEN `BLOCKER,CRITICAL,MAJOR` issues:
| Severity | Count |
|----------|-------|
| BLOCKER | 0 |
| CRITICAL | 200 |
| MAJOR | 448 |
| Total | 648 |
Top rules:
| Rule | Count |
|------|-------|
| `php:S1142` | 160 |
| `php:S1192` | 117 |
| `php:S4833` | 97 |
| `php:S3776` | 57 |
| `php:S112` | 46 |
| `php:S1172` | 42 |
| `php:S121` | 24 |
| `php:S3358` | 23 |
| `Web:TableHeaderHasIdOrScopeCheck` | 16 |
| `php:S1448` | 16 |
## Selected Plan 139-01 Targets
| File | Fresh issues | Final issues | Notes |
|------|--------------|--------------|-------|
| `src/Modules/Shipments/DeliveryStatusRepository.php` | 6 | 0 | Replaced generic runtime exceptions with `DeliveryStatusException`; extracted guard/count helpers. |
| `src/Modules/Shipments/DeliveryStatus.php` | 10 | 0 | Replaced repeated status strings with constants; table-driven normalization, description guessing, carrier URL matching. |
| `src/Modules/Settings/DeliveryStatusesController.php` | 4 | 0 | Removed unused handler parameter, flattened validation, centralized repeated error prefix. |
| `src/Modules/Settings/DeliveryStatusMappingController.php` | 3 | 0 | Centralized provider redirects and required-field flash message; reduced `save()` returns. |
| `resources/views/settings/delivery-statuses.php` | 3 | 0 | Replaced direct includes with `$component()`. |
| `resources/views/settings/delivery-status-mappings.php` | 1 | 0 | Replaced direct partial include with `$component()`. |
| `src/Modules/Statistics/OrdersStatisticsRepository.php` | 17 | 1 | Reduced repeated literals, excessive returns, and `netAmountSql()`/column-cache complexity. Remaining: `php:S1448` god-class split. |
## Final Scan After Cleanup
Analysis id: `e1372dbf-66aa-4135-9f1d-245a285617b9`
OPEN `BLOCKER,CRITICAL,MAJOR` issues:
| Severity | Before | After | Delta |
|----------|--------|-------|-------|
| BLOCKER | 0 | 0 | 0 |
| CRITICAL | 200 | 181 | -19 |
| MAJOR | 448 | 424 | -24 |
| Total | 648 | 605 | -43 |
Top rules after cleanup:
| Rule | Count |
|------|-------|
| `php:S1142` | 148 |
| `php:S1192` | 101 |
| `php:S4833` | 93 |
| `php:S3776` | 54 |
| `php:S1172` | 41 |
| `php:S112` | 40 |
| `php:S121` | 24 |
| `php:S3358` | 23 |
| `Web:TableHeaderHasIdOrScopeCheck` | 16 |
| `php:S1448` | 16 |
## Remaining Targeted Issue
- `src/Modules/Statistics/OrdersStatisticsRepository.php`: `php:S1448`, 43 methods. This requires a class split and belongs with the Phase 141 god-class work or a dedicated Phase 139 follow-up slice.
## Tooling Notes
- `vendor/bin/phpunit` is not available because `vendor/` is missing and Composer is not installed in PATH.
- Verification used PHP lint plus ad-hoc runtime smoke checks via `tests/bootstrap.php` and SQLite in-memory data.

View File

@@ -2,5 +2,5 @@ projectKey=orderPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=orderPRO
ceTaskId=eb3d3f09-2a6e-4fd3-a7ae-5c3724064a7f
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=eb3d3f09-2a6e-4fd3-a7ae-5c3724064a7f
ceTaskId=e30b3287-a514-49da-b6fc-699f4e42e9b0
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=e30b3287-a514-49da-b6fc-699f4e42e9b0

View File

@@ -1017,20 +1017,20 @@
"DOCS": {
"ARCHITECTURE.md": {
"type": "-",
"size": 29363,
"lmtime": 1778940898098,
"size": 30112,
"lmtime": 1778960313779,
"modified": false
},
"DB_SCHEMA.md": {
"type": "-",
"size": 47673,
"lmtime": 1778941087323,
"size": 48041,
"lmtime": 1778960376041,
"modified": false
},
"TECH_CHANGELOG.md": {
"type": "-",
"size": 23301,
"lmtime": 1778941093059,
"size": 24480,
"lmtime": 1778960398485,
"modified": false
}
},
@@ -3364,9 +3364,9 @@
},
"ReceiptService.php": {
"type": "-",
"size": 10962,
"lmtime": 1775462779485,
"modified": true
"size": 11424,
"lmtime": 1778960082901,
"modified": false
}
},
"Auth": {
@@ -4390,8 +4390,8 @@
},
"OrdersStatisticsRepository.php": {
"type": "-",
"size": 27780,
"lmtime": 1778940919269,
"size": 32177,
"lmtime": 1778960744050,
"modified": false
}
}

View File

@@ -76,6 +76,19 @@ HTTP Request
- `Template::renderFile()` exposes a `$component($view, $params = [])` helper for reusable PHP components and partials.
- Phase 138 migrated targeted hard `require` usages in order/accounting/user views to `$component()`. Alert component includes from Phase 120 remain an accepted current pattern unless a later broad component-rendering refactor is planned.
## Phase 139 Sonar Cleanup Slice
### Delivery status cleanup
- `DeliveryStatusException` (`src/Core/Exceptions/DeliveryStatusException.php`) is the domain exception for delivery-status CRUD guard failures.
- `DeliveryStatusRepository` keeps `create/update/delete` thin and delegates duplicate-key, not-found, system-status and in-use checks to small guard/count helpers.
- `DeliveryStatus` uses provider maps directly for `normalize()` / `description()`, pattern tables for description fallback, and carrier/provider URL tables for tracking links.
- Delivery status settings views render the mapping partial through `Template::$component()` instead of direct `include`, matching the reusable component boundary.
### Statistics cleanup
- `OrdersStatisticsRepository` now caches detected `orders` columns in a single static map instead of per-column static properties.
- Amount SQL generation is split into small helpers while preserving the Phase 135 contract: source-level net first, VAT-aware item fallback second, legacy gross `/1.23` last, and delivery net at 23% only when source net is missing.
- Remaining architecture debt: `OrdersStatisticsRepository` still exceeds the Sonar method-count threshold and should be split in a later god-class/refactor slice.
## Frontend Enhancement Modules
### Checkbox Multiselect (`public/assets/js/modules/checkbox-multiselect.js`)

View File

@@ -1,6 +1,6 @@
# Database Schema
**Updated:** 2026-05-16 | **Total tables:** 63 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
**Updated:** 2026-05-17 | **Total tables:** 63 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
---
@@ -1084,4 +1084,5 @@ Default keys: `cron_run_on_web`, `cron_web_limit`, `gs1_api_login`, `gs1_prefix`
- 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.
- Phase 139 refactored statistics SQL generation only; it did not add tables, columns, indexes, or migrations.
- No schema migration was introduced for Phase 110.

View File

@@ -1,5 +1,22 @@
# Technical Changelog
## 2026-05-17 - Phase 139 Plan 01: Sonar Critical/Major Cleanup
**Co zrobiono:**
- Uruchomiono swiezy SonarScanner z konfiguracji `sonar-project.properties`; baseline OPEN BLOCKER/CRITICAL/MAJOR wynosil 648, a finalny skan po zmianach 605.
- Dodano `DeliveryStatusException` i zastapiono generyczne `RuntimeException` w `DeliveryStatusRepository` domenowymi wyjatkami.
- Uproszczono `DeliveryStatus`: mapowania providerow, fallback statusu z opisu i linki sledzenia sa teraz tabelaryczne, bez dlugich lancuchow `if`/`match` i powtarzanych literalow.
- Oczyszczono kontrolery ustawien statusow dostawy z powtarzanych komunikatow/redirectow i nadmiarowych `return`.
- Widoki statusow dostawy renderuja alerty i partial mapowania przez `$component()`, bez bezposrednich `include`.
- W `OrdersStatisticsRepository` uproszczono cache wykrywania kolumn, fragmenty SQL dat/IN/ROUND oraz generowanie SQL kwot netto/brutto bez zmiany kontraktu Phase 135.
- Zaktualizowano test `OrdersStatisticsRepositoryTest` do nowego cache kolumn.
**Dlaczego:**
- Phase 139 ma redukowac potwierdzone problemy Sonar na aktualnym skanie, nie na starym baseline. Pierwsza fala usuwa najbezpieczniejszy, spójny klaster: statusy dostawy i część statystyk.
**BREAKING / migracja:**
- Brak migracji DB i brak zmian kontraktu API/UI. Pozostaje `php:S1448` w `OrdersStatisticsRepository`, bo wymaga osobnego splitu klasy.
## 2026-05-17 - Phase 138 Plan 01: Security and Legacy Hardening
**Co zrobiono:**

16
DOCS/todo.md Normal file
View File

@@ -0,0 +1,16 @@
# Technical TODO
## SonarQube - 2026-05-17
Fresh Phase 139 scan after plan 139-01: 605 OPEN `BLOCKER,CRITICAL,MAJOR` issues remain (`BLOCKER=0`, `CRITICAL=181`, `MAJOR=424`).
Next recommended slices:
- Split `src/Modules/Statistics/OrdersStatisticsRepository.php` (`php:S1448`, 43 methods) into query/amount/schema helpers before making deeper statistics changes.
- Continue with the largest confirmed rule groups: `php:S1142`, `php:S1192`, `php:S4833`, `php:S3776`, `php:S1172`, `php:S112`.
- Handle Web accessibility groups separately so table headers, `<output>` semantics and icon-only labels can be checked in the UI.
Resolved in Phase 139-01:
- Delivery status repository/controller/view cluster: 27 selected issues removed.
- Statistics repository selected complexity/literal/return issues reduced; only the god-class split remains in the targeted statistics file.

View File

@@ -1,3 +1,2 @@
<?php
$mappingBaseUrl = '/settings/delivery-status-mappings';
include __DIR__ . '/_delivery-status-mappings-content.php';
$component('settings/_delivery-status-mappings-content', ['mappingBaseUrl' => '/settings/delivery-status-mappings']);

View File

@@ -9,10 +9,10 @@ $csrfToken = (string) ($csrfToken ?? '');
<section class="card">
<h2 class="section-title">Statusy przesyłek</h2>
<?php if ($errorMessage !== ''): ?>
<div class="mt-12"><?php $type='danger'; $message=(string) $errorMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<div class="mt-12"><?php $component('components/alert', ['type' => 'danger', 'message' => $errorMessage, 'dismissible' => true]); ?></div>
<?php endif; ?>
<?php if ($successMessage !== ''): ?>
<div class="mt-12"><?php $type='success'; $message=(string) $successMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<div class="mt-12"><?php $component('components/alert', ['type' => 'success', 'message' => $successMessage, 'dismissible' => true]); ?></div>
<?php endif; ?>
</section>
@@ -86,10 +86,7 @@ $csrfToken = (string) ($csrfToken ?? '');
<?php else: ?>
<?php
$mappingBaseUrl = '/settings/delivery-statuses?tab=mapping';
include __DIR__ . '/_delivery-status-mappings-content.php';
?>
<?php $component('settings/_delivery-status-mappings-content', ['mappingBaseUrl' => '/settings/delivery-statuses?tab=mapping']); ?>
<?php endif; ?>
</section>

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Core\Exceptions;
final class DeliveryStatusException extends OrderProException
{
public static function duplicateKey(): self
{
return new self("Status o tym kluczu ju\u{017C} istnieje.");
}
public static function notFound(): self
{
return new self('Status nie istnieje.');
}
public static function systemStatusCannotBeEdited(): self
{
return new self("Status\u{00F3}w systemowych nie mo\u{017C}na edytowa\u{0107}.");
}
public static function systemStatusCannotBeDeleted(): self
{
return new self("Status\u{00F3}w systemowych nie mo\u{017C}na usun\u{0105}\u{0107}.");
}
public static function inUse(): self
{
return new self("Status jest u\u{017C}ywany i nie mo\u{017C}e by\u{0107} usuni\u{0119}ty.");
}
}

View File

@@ -18,6 +18,8 @@ use Throwable;
final class DeliveryStatusMappingController
{
private const REDIRECT_PATH = '/settings/delivery-statuses?tab=mapping';
private const PROVIDER_QUERY_PREFIX = '&provider=';
private const REQUIRED_FIELDS_MESSAGE = "Brakuje wymaganych p\u{00F3}l.";
private const PROVIDERS = [
'inpost' => 'InPost',
@@ -109,14 +111,9 @@ final class DeliveryStatusMappingController
$normalizedStatus = trim((string) $request->input('normalized_status', ''));
$description = trim((string) $request->input('description', ''));
if ($provider === '' || $rawStatus === '') {
Flash::set('dsm_error', 'Brakuje wymaganych pól.');
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
}
if ($this->deliveryStatusRepository->getByKey($normalizedStatus) === null) {
Flash::set('dsm_error', 'Nieprawidłowy status znormalizowany.');
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
$validationError = $this->validateSaveInput($provider, $rawStatus, $normalizedStatus);
if ($validationError !== null) {
return $validationError;
}
try {
@@ -126,7 +123,7 @@ final class DeliveryStatusMappingController
Flash::set('dsm_error', 'Błąd zapisu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
return $this->redirectToProvider($provider);
}
public function saveBulk(Request $request): Response
@@ -143,7 +140,7 @@ final class DeliveryStatusMappingController
if (!is_array($rawStatuses) || !is_array($normalizedStatuses) || !is_array($descriptions)) {
Flash::set('dsm_error', 'Nieprawidłowe dane formularza.');
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
return $this->redirectToProvider($provider);
}
try {
@@ -181,7 +178,7 @@ final class DeliveryStatusMappingController
Flash::set('dsm_error', 'Błąd zapisu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
return $this->redirectToProvider($provider);
}
public function reset(Request $request): Response
@@ -195,8 +192,8 @@ final class DeliveryStatusMappingController
$rawStatus = trim((string) $request->input('raw_status', ''));
if ($provider === '' || $rawStatus === '') {
Flash::set('dsm_error', 'Brakuje wymaganych pól.');
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
$this->setRequiredFieldsError();
return $this->redirectToProvider($provider);
}
try {
@@ -206,7 +203,7 @@ final class DeliveryStatusMappingController
Flash::set('dsm_error', 'Błąd resetu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
return $this->redirectToProvider($provider);
}
public function resetAll(Request $request): Response
@@ -218,7 +215,7 @@ final class DeliveryStatusMappingController
$provider = strtolower(trim((string) $request->input('provider', '')));
if ($provider === '') {
Flash::set('dsm_error', 'Brakuje wymaganych pól.');
$this->setRequiredFieldsError();
return Response::redirect(self::REDIRECT_PATH);
}
@@ -229,7 +226,7 @@ final class DeliveryStatusMappingController
Flash::set('dsm_error', 'Błąd resetu: ' . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_PATH . '&provider=' . rawurlencode($provider));
return $this->redirectToProvider($provider);
}
private function validateCsrf(string $token): ?Response
@@ -241,4 +238,29 @@ final class DeliveryStatusMappingController
Flash::set('dsm_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect(self::REDIRECT_PATH);
}
private function validateSaveInput(string $provider, string $rawStatus, string $normalizedStatus): ?Response
{
if ($provider === '' || $rawStatus === '') {
$this->setRequiredFieldsError();
return $this->redirectToProvider($provider);
}
if ($this->deliveryStatusRepository->getByKey($normalizedStatus) !== null) {
return null;
}
Flash::set('dsm_error', 'Nieprawidłowy status znormalizowany.');
return $this->redirectToProvider($provider);
}
private function redirectToProvider(string $provider): Response
{
return Response::redirect(self::REDIRECT_PATH . self::PROVIDER_QUERY_PREFIX . rawurlencode($provider));
}
private function setRequiredFieldsError(): void
{
Flash::set('dsm_error', self::REQUIRED_FIELDS_MESSAGE);
}
}

View File

@@ -18,6 +18,7 @@ use Throwable;
final class DeliveryStatusesController
{
private const REDIRECT_STATUSES = '/settings/delivery-statuses?tab=statuses';
private const ERROR_PREFIX = "B\u{0142}\u{0105}d: ";
private const PROVIDERS = [
'inpost' => 'InPost',
@@ -63,7 +64,7 @@ final class DeliveryStatusesController
return Response::html($html);
}
public function create(Request $request): Response
public function create(): Response
{
return $this->renderForm(null);
}
@@ -121,7 +122,7 @@ final class DeliveryStatusesController
]);
Flash::set('ds_success', 'Status dodany.');
} catch (Throwable $exception) {
Flash::set('ds_error', 'Błąd: ' . $exception->getMessage());
Flash::set('ds_error', self::ERROR_PREFIX . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_STATUSES);
@@ -155,7 +156,7 @@ final class DeliveryStatusesController
]);
Flash::set('ds_success', 'Status zaktualizowany.');
} catch (Throwable $exception) {
Flash::set('ds_error', 'Błąd: ' . $exception->getMessage());
Flash::set('ds_error', self::ERROR_PREFIX . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_STATUSES);
@@ -174,7 +175,7 @@ final class DeliveryStatusesController
$this->deliveryStatusRepository->delete($id);
Flash::set('ds_success', 'Status usunięty.');
} catch (Throwable $exception) {
Flash::set('ds_error', 'Błąd: ' . $exception->getMessage());
Flash::set('ds_error', self::ERROR_PREFIX . $exception->getMessage());
}
return Response::redirect(self::REDIRECT_STATUSES);
@@ -213,23 +214,48 @@ final class DeliveryStatusesController
private function validateFields(string $key, string $labelPl, string $color, bool $validateKey): ?string
{
if ($validateKey) {
if ($key === '' || !preg_match('/^[a-z][a-z0-9_]{0,49}$/', $key)) {
return 'Klucz statusu jest nieprawidłowy (tylko małe litery, cyfry, _, max 50 znaków, zaczyna się literą).';
foreach ($this->deliveryStatusFieldErrors($key, $labelPl, $color, $validateKey) as $error) {
if ($error !== null) {
return $error;
}
}
if ($labelPl === '' || mb_strlen($labelPl) > 100) {
return 'Etykieta jest wymagana i nie może przekraczać 100 znaków.';
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
return 'Kolor musi być w formacie #RRGGBB.';
}
return null;
}
/**
* @return list<string|null>
*/
private function deliveryStatusFieldErrors(string $key, string $labelPl, string $color, bool $validateKey): array
{
return [
$validateKey ? $this->validateStatusKey($key) : null,
$this->validateStatusLabel($labelPl),
$this->validateStatusColor($color),
];
}
private function validateStatusKey(string $key): ?string
{
return $key === '' || !preg_match('/^[a-z][a-z0-9_]{0,49}$/', $key)
? 'Klucz statusu jest nieprawidłowy (tylko małe litery, cyfry, _, max 50 znaków, zaczyna się literą).'
: null;
}
private function validateStatusLabel(string $labelPl): ?string
{
return $labelPl === '' || mb_strlen($labelPl) > 100
? 'Etykieta jest wymagana i nie może przekraczać 100 znaków.'
: null;
}
private function validateStatusColor(string $color): ?string
{
return preg_match('/^#[0-9a-fA-F]{6}$/', $color)
? null
: 'Kolor musi być w formacie #RRGGBB.';
}
/**
* @return array<string, mixed>
*/

View File

@@ -25,15 +25,24 @@ final class DeliveryStatus
self::CANCELLED,
];
private const DESC_PICKED_UP_BY_COURIER = 'Odebrana przez kuriera';
private const DESC_DISPATCHED = "Przesy\u{0142}ka nadana";
private const DESC_OUT_FOR_DELIVERY = "W dor\u{0119}czeniu";
private const DESC_DELIVERED = "Dor\u{0119}czona";
private const DESC_RETURNED_TO_SENDER = "Zwr\u{00F3}cona do nadawcy";
private const DESC_AWAITING_PICKUP = "Oczekuje na odbi\u{00F3}r";
private const TRACKING_INPOST_URL = 'https://inpost.pl/sledzenie-przesylek?number=';
private const TRACKING_ALLEGRO_URL = 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=';
public const LABEL_PL = [
self::UNKNOWN => 'Nieznany',
self::CREATED => 'Utworzona',
self::CONFIRMED => 'Potwierdzona',
self::PICKED_UP => 'Odebrana przez kuriera',
self::PICKED_UP => self::DESC_PICKED_UP_BY_COURIER,
self::IN_TRANSIT => 'W tranzycie',
self::OUT_FOR_DELIVERY => 'W doręczeniu',
self::OUT_FOR_DELIVERY => self::DESC_OUT_FOR_DELIVERY,
self::READY_FOR_PICKUP => 'Gotowa do odbioru',
self::DELIVERED => 'Doręczona',
self::DELIVERED => self::DESC_DELIVERED,
self::RETURNED => 'Zwrócona',
self::CANCELLED => 'Anulowana',
self::PROBLEM => 'Problem',
@@ -86,9 +95,9 @@ final class DeliveryStatus
'offers_prepared' => 'Oferty cenowe przygotowane',
'offer_selected' => 'Oferta wybrana',
'confirmed' => 'Przesyłka potwierdzona',
'dispatched' => 'Przesyłka nadana',
'dispatched' => self::DESC_DISPATCHED,
'collected' => 'Odebrana od nadawcy',
'taken_by_courier' => 'Odebrana przez kuriera',
'taken_by_courier' => self::DESC_PICKED_UP_BY_COURIER,
'adopted_at_source_branch' => 'Przyjęta w oddziale źródłowym',
'adopted_at_sorting_center' => 'Przyjęta w centrum sortowania',
'sent_from_sorting_center' => 'Wysłana z centrum sortowania',
@@ -101,9 +110,9 @@ final class DeliveryStatus
'ready_to_pickup_from_pok' => 'Gotowa do odbioru z POK',
'stack_in_box_machine' => 'Umieszczona w paczkomacie',
'stack_in_customer_service_point' => 'Umieszczona w punkcie obsługi',
'delivered' => 'Doręczona',
'delivered' => self::DESC_DELIVERED,
'claimed' => 'Odebrana po awizo',
'returned_to_sender' => 'Zwrócona do nadawcy',
'returned_to_sender' => self::DESC_RETURNED_TO_SENDER,
'undelivered' => 'Niedoręczona',
'undelivered_wrong_address' => 'Niedoręczona — błędny adres',
'undelivered_incomplete_address' => 'Niedoręczona — niepełny adres',
@@ -154,28 +163,28 @@ final class DeliveryStatus
private const APACZKA_DESCRIPTIONS = [
'0' => 'Oczekuje na przetworzenie',
'1' => 'Zamówienie potwierdzone',
'2' => 'Odebrana przez kuriera',
'2' => self::DESC_PICKED_UP_BY_COURIER,
'3' => 'W transporcie',
'4' => 'W doręczeniu',
'5' => 'Doręczona',
'6' => 'Zwrócona do nadawcy',
'4' => self::DESC_OUT_FOR_DELIVERY,
'5' => self::DESC_DELIVERED,
'6' => self::DESC_RETURNED_TO_SENDER,
'7' => 'Anulowana',
'8' => 'Błąd zamówienia',
'9' => 'Oczekuje na odbiór w punkcie',
'9' => self::DESC_AWAITING_PICKUP . ' w punkcie',
'10' => 'Przekierowana',
'NEW' => 'Zamówienie utworzone',
'PENDING' => 'Oczekuje na przetworzenie',
'CONFIRMED' => 'Zamówienie potwierdzone',
'PICKED_UP' => 'Odebrana przez kuriera',
'PICKUP' => 'Odebrana przez kuriera',
'PICKED_UP' => self::DESC_PICKED_UP_BY_COURIER,
'PICKUP' => self::DESC_PICKED_UP_BY_COURIER,
'IN_TRANSIT' => 'W transporcie',
'OUT_FOR_DELIVERY' => 'W doręczeniu',
'DELIVERED' => 'Doręczona',
'RETURNED' => 'Zwrócona do nadawcy',
'RETURNED_TO_SHIPPER' => 'Zwrócona do nadawcy',
'OUT_FOR_DELIVERY' => self::DESC_OUT_FOR_DELIVERY,
'DELIVERED' => self::DESC_DELIVERED,
'RETURNED' => self::DESC_RETURNED_TO_SENDER,
'RETURNED_TO_SHIPPER' => self::DESC_RETURNED_TO_SENDER,
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd zamówienia',
'WAITING_FOR_PICKUP' => 'Oczekuje na odbiór w punkcie',
'WAITING_FOR_PICKUP' => self::DESC_AWAITING_PICKUP . ' w punkcie',
'REDIRECT' => 'Przekierowana',
];
@@ -195,10 +204,10 @@ final class DeliveryStatus
'READY_TO_SHIP' => 'Etykieta wygenerowana, oczekuje na nadanie',
'collected_from_sender' => 'Odebrana od nadawcy',
'IN_TRANSIT' => 'Odebrana przez przewoźnika',
'DELIVERED' => 'Doręczona',
'DELIVERED' => self::DESC_DELIVERED,
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd przetwarzania',
'RETURNED' => 'Zwrócona do nadawcy',
'RETURNED' => self::DESC_RETURNED_TO_SENDER,
];
private const ALLEGRO_EDGE_MAP = [
@@ -246,13 +255,13 @@ final class DeliveryStatus
private const ALLEGRO_EDGE_DESCRIPTIONS = [
'przygotowana_przez_nadawce' => 'Przesyłka przygotowana przez nadawcę',
'prepared_by_the_sender' => 'Przesyłka przygotowana przez nadawcę',
'nadana' => 'Przesyłka nadana',
'dispatched' => 'Przesyłka nadana',
'nadana' => self::DESC_DISPATCHED,
'dispatched' => self::DESC_DISPATCHED,
'podjeta_z_maszyny_przez_kuriera' => 'Podjęta z maszyny przez kuriera',
'podjeta_z_punktu_przez_kuriera' => 'Podjęta z punktu przez kuriera',
'odebrana_przez_kuriera' => 'Odebrana przez kuriera',
'odebrana_przez_kuriera' => self::DESC_PICKED_UP_BY_COURIER,
'picked_up_from_point_by_courier' => 'Podjęta z punktu przez kuriera',
'picked_up_by_the_courier' => 'Odebrana przez kuriera',
'picked_up_by_the_courier' => self::DESC_PICKED_UP_BY_COURIER,
'przekazana_do_magazynu' => 'Przekazana do magazynu',
'transferred_the_parcel_to_the_warehouse' => 'Przekazana do magazynu',
'accepted_at_the_branch' => 'Przyjęta w oddziale',
@@ -260,21 +269,21 @@ final class DeliveryStatus
'w_sortowni' => 'W sortowni',
'wyjechala_w_droge_do_punktu_docelowego' => 'Wyjechała w drogę do punktu docelowego',
'wyslana_z_sortowni' => 'Wysłana z sortowni',
'w_doreczeniu' => 'W doręczeniu',
'w_doreczeniu' => self::DESC_OUT_FOR_DELIVERY,
'wydana_do_doreczenia' => 'Wydana do doręczenia',
'released_for_delivery' => 'Wydana do doręczenia',
'dostarczana' => 'Dostarczana',
'gotowa_do_odbioru' => 'Gotowa do odbioru',
'oczekuje_na_odbior' => 'Oczekuje na odbiór',
'przesylka_oczekuje_na_odbior' => 'Oczekuje na odbiór',
'awaiting_pick_up' => 'Oczekuje na odbiór',
'oczekuje_na_odbior' => self::DESC_AWAITING_PICKUP,
'przesylka_oczekuje_na_odbior' => self::DESC_AWAITING_PICKUP,
'awaiting_pick_up' => self::DESC_AWAITING_PICKUP,
'dostarczona' => 'Dostarczona',
'doreczona' => 'Doręczona',
'doreczona' => self::DESC_DELIVERED,
'odebrana' => 'Odebrana',
'delivered' => 'Dostarczona',
'zwrocona' => 'Zwrócona',
'zwrocona_do_nadawcy' => 'Zwrócona do nadawcy',
'returned_to_the_sender' => 'Zwrócona do nadawcy',
'zwrocona_do_nadawcy' => self::DESC_RETURNED_TO_SENDER,
'returned_to_the_sender' => self::DESC_RETURNED_TO_SENDER,
'anulowana' => 'Anulowana',
'cancelled' => 'Anulowana',
'odmowa_przyjecia' => 'Odmowa przyjęcia',
@@ -333,6 +342,97 @@ final class DeliveryStatus
'polkurier' => self::POLKURIER_DESCRIPTIONS,
];
private const DESCRIPTION_STATUS_PATTERNS = [
self::DELIVERED => [
'delivered',
'picked up by recipient',
'doręczon',
'dostarczono',
'odebrana przez odbiorc',
],
self::PICKED_UP => [
'picked up by courier',
'picked up from point',
'podjęta',
'podjeta',
'odebrana przez kuriera',
],
self::RETURNED => [
'returned',
'zwrócon',
'zwrocona',
],
self::CANCELLED => [
'cancelled',
'canceled',
'anulowan',
],
self::OUT_FOR_DELIVERY => [
'out for delivery',
'released for delivery',
'doręczeni',
'doreczenia',
'wydana do',
],
self::READY_FOR_PICKUP => [
'awaiting pick-up',
'awaiting pickup',
'ready for pickup',
'ready for pick-up',
'oczekuje na odb',
'gotowa do odb',
],
self::IN_TRANSIT => [
'courier',
'warehouse',
'branch',
'in transit',
'sortowni',
'magazyn',
'w drodze',
'tranzyt',
'kurier',
'wyjechał',
'wyjechala',
],
self::CONFIRMED => [
'dispatched',
'nadana',
'nadano',
],
self::CREATED => [
'prepared',
'created',
'przygotowan',
'utworzon',
],
self::PROBLEM => [
'damaged',
'problem',
'lost',
'uszkodzon',
'zagubion',
],
];
private const PROVIDER_TRACKING_URLS = [
'inpost' => self::TRACKING_INPOST_URL,
'allegro_wza' => self::TRACKING_ALLEGRO_URL,
'polkurier' => 'https://polkurier.pl/sledz-paczke/',
];
private const CARRIER_TRACKING_URLS = [
[['dpd'], 'https://tracktrace.dpd.com.pl/parcelDetails?p1='],
[['dhl'], 'https://www.dhl.com/pl-pl/home/sledzenie-przesylki.html?tracking-id='],
[['inpost', 'paczkomat'], self::TRACKING_INPOST_URL],
[['orlen', 'ruch'], 'https://www.orlenpaczka.pl/sledz-paczke/?numer='],
[['poczta', 'pocztex'], 'https://emonitoring.poczta-polska.pl/?numer='],
[['ups'], 'https://www.ups.com/track?tracknum='],
[['fedex'], 'https://www.fedex.com/fedextrack/?trknbr='],
[['gls'], 'https://gls-group.com/PL/pl/sledzenie-paczek?match='],
[['allegro'], self::TRACKING_ALLEGRO_URL],
];
/**
* @return array<string, array{normalized: string, description: string}>
*/
@@ -380,30 +480,12 @@ final class DeliveryStatus
public static function normalize(string $provider, string $rawStatus): string
{
$map = match ($provider) {
'inpost' => self::INPOST_MAP,
'apaczka' => self::APACZKA_MAP,
'allegro_wza' => self::ALLEGRO_MAP,
'allegro_edge' => self::ALLEGRO_EDGE_MAP,
'polkurier' => self::POLKURIER_MAP,
default => [],
};
return $map[$rawStatus] ?? self::UNKNOWN;
return self::PROVIDER_MAPS[$provider][$rawStatus] ?? self::UNKNOWN;
}
public static function description(string $provider, string $rawStatus): string
{
$map = match ($provider) {
'inpost' => self::INPOST_DESCRIPTIONS,
'apaczka' => self::APACZKA_DESCRIPTIONS,
'allegro_wza' => self::ALLEGRO_DESCRIPTIONS,
'allegro_edge' => self::ALLEGRO_EDGE_DESCRIPTIONS,
'polkurier' => self::POLKURIER_DESCRIPTIONS,
default => [],
};
return $map[$rawStatus] ?? $rawStatus;
return self::PROVIDER_DESCRIPTIONS[$provider][$rawStatus] ?? $rawStatus;
}
public static function setRepository(DeliveryStatusRepository $repo): void
@@ -490,69 +572,15 @@ final class DeliveryStatus
{
$lower = mb_strtolower($description, 'UTF-8');
if (str_contains($lower, 'delivered') || str_contains($lower, 'picked up by recipient')) {
return self::DELIVERED;
foreach (self::DESCRIPTION_STATUS_PATTERNS as $status => $patterns) {
if (self::containsAny($lower, $patterns)) {
return $status;
}
if (str_contains($lower, 'picked up by courier') || str_contains($lower, 'picked up from point')) {
return self::PICKED_UP;
}
if (str_contains($lower, 'returned')) {
return self::RETURNED;
}
if (str_contains($lower, 'cancelled') || str_contains($lower, 'canceled')) {
return self::CANCELLED;
}
if (str_contains($lower, 'out for delivery') || str_contains($lower, 'released for delivery')) {
return self::OUT_FOR_DELIVERY;
}
if (str_contains($lower, 'awaiting pick-up') || str_contains($lower, 'awaiting pickup') || str_contains($lower, 'ready for pickup') || str_contains($lower, 'ready for pick-up')) {
return self::READY_FOR_PICKUP;
}
if (str_contains($lower, 'courier') || str_contains($lower, 'warehouse') || str_contains($lower, 'branch') || str_contains($lower, 'in transit')) {
return self::IN_TRANSIT;
}
if (str_contains($lower, 'dispatched')) {
return self::CONFIRMED;
}
if (str_contains($lower, 'prepared') || str_contains($lower, 'created')) {
return self::CREATED;
}
if (str_contains($lower, 'damaged') || str_contains($lower, 'problem') || str_contains($lower, 'lost')) {
return self::PROBLEM;
}
if (str_contains($lower, 'doręczon') || str_contains($lower, 'dostarczono') || str_contains($lower, 'odebrana przez odbiorc')) {
return self::DELIVERED;
}
if (str_contains($lower, 'zwrócon') || str_contains($lower, 'zwrocona')) {
return self::RETURNED;
}
if (str_contains($lower, 'anulowan')) {
return self::CANCELLED;
}
if (str_contains($lower, 'doręczeni') || str_contains($lower, 'doreczenia') || str_contains($lower, 'wydana do')) {
return self::OUT_FOR_DELIVERY;
}
if (str_contains($lower, 'podjęta') || str_contains($lower, 'podjeta') || str_contains($lower, 'odebrana przez kuriera')) {
return self::PICKED_UP;
}
if (str_contains($lower, 'sortowni') || str_contains($lower, 'magazyn') || str_contains($lower, 'w drodze') || str_contains($lower, 'tranzyt') || str_contains($lower, 'kurier') || str_contains($lower, 'wyjechał') || str_contains($lower, 'wyjechala')) {
return self::IN_TRANSIT;
}
if (str_contains($lower, 'oczekuje na odb') || str_contains($lower, 'gotowa do odb') || (str_contains($lower, 'odbiór') && !str_contains($lower, 'w drodze'))) {
return self::READY_FOR_PICKUP;
}
if (str_contains($lower, 'nadana') || str_contains($lower, 'nadano')) {
return self::CONFIRMED;
}
if (str_contains($lower, 'przygotowan') || str_contains($lower, 'utworzon')) {
return self::CREATED;
}
if (str_contains($lower, 'uszkodzon') || str_contains($lower, 'problem') || str_contains($lower, 'zagubion')) {
return self::PROBLEM;
}
return self::UNKNOWN;
return str_contains($lower, "odbi\u{00F3}r") && !str_contains($lower, 'w drodze')
? self::READY_FOR_PICKUP
: self::UNKNOWN;
}
public static function trackingUrl(string $provider, string $trackingNumber, string $carrierId = ''): ?string
@@ -563,59 +591,67 @@ final class DeliveryStatus
}
$encoded = rawurlencode($number);
$providerUrl = self::providerTrackingUrl($provider, $encoded);
if ($provider === 'inpost') {
return 'https://inpost.pl/sledzenie-przesylek?number=' . $encoded;
return $providerUrl;
}
if ($carrierId !== '') {
$url = self::matchCarrierByName($encoded, strtolower(trim($carrierId)));
if ($url !== null) {
return $url;
}
return self::carrierTrackingUrl($encoded, $carrierId) ?? $providerUrl ?? self::fallbackTrackingUrl($encoded);
}
if ($provider === 'allegro_wza') {
return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded;
private static function carrierTrackingUrl(string $encoded, string $carrierId): ?string
{
$carrier = strtolower(trim($carrierId));
return $carrier === '' ? null : self::matchCarrierByName($encoded, $carrier);
}
if ($provider === 'polkurier') {
return 'https://polkurier.pl/sledz-paczke/' . $encoded;
private static function providerTrackingUrl(string $provider, string $encoded): ?string
{
$baseUrl = self::PROVIDER_TRACKING_URLS[$provider] ?? null;
return $baseUrl === null ? null : $baseUrl . $encoded;
}
private static function fallbackTrackingUrl(string $encoded): string
{
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
}
private static function matchCarrierByName(string $encoded, string $carrier): ?string
{
if (str_contains($carrier, 'dpd')) {
return 'https://tracktrace.dpd.com.pl/parcelDetails?p1=' . $encoded;
foreach (self::CARRIER_TRACKING_URLS as [$patterns, $baseUrl]) {
if (self::carrierMatches($carrier, $patterns)) {
return $baseUrl . $encoded;
}
if (str_contains($carrier, 'dhl')) {
return 'https://www.dhl.com/pl-pl/home/sledzenie-przesylki.html?tracking-id=' . $encoded;
}
if (str_contains($carrier, 'inpost') || str_contains($carrier, 'paczkomat')) {
return 'https://inpost.pl/sledzenie-przesylek?number=' . $encoded;
}
if (str_contains($carrier, 'orlen') || str_contains($carrier, 'ruch')) {
return 'https://www.orlenpaczka.pl/sledz-paczke/?numer=' . $encoded;
}
if (str_contains($carrier, 'poczta') || str_contains($carrier, 'pocztex')) {
return 'https://emonitoring.poczta-polska.pl/?numer=' . $encoded;
}
if (str_contains($carrier, 'ups')) {
return 'https://www.ups.com/track?tracknum=' . $encoded;
}
if (str_contains($carrier, 'fedex')) {
return 'https://www.fedex.com/fedextrack/?trknbr=' . $encoded;
}
if (str_contains($carrier, 'gls')) {
return 'https://gls-group.com/PL/pl/sledzenie-paczek?match=' . $encoded;
}
if ($carrier === 'allegro') {
return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded;
}
return null;
}
/**
* @param array<int, string> $needles
*/
private static function containsAny(string $haystack, array $needles): bool
{
foreach ($needles as $needle) {
if (str_contains($haystack, $needle)) {
return true;
}
}
return false;
}
/**
* @param array<int, string> $patterns
*/
private static function carrierMatches(string $carrier, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($carrier === $pattern || str_contains($carrier, $pattern)) {
return true;
}
}
return false;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Core\Exceptions\DeliveryStatusException;
use PDO;
final class DeliveryStatusRepository
@@ -53,9 +54,7 @@ final class DeliveryStatusRepository
public function create(array $data): int
{
if ($this->getByKey($data['key']) !== null) {
throw new \RuntimeException('Status o tym kluczu już istnieje.');
}
$this->assertKeyIsAvailable((string) $data['key']);
$statement = $this->db->prepare(
'INSERT INTO delivery_statuses (`key`, label_pl, color, sort_order, is_terminal)
@@ -75,14 +74,8 @@ final class DeliveryStatusRepository
public function update(int $id, array $data): void
{
$row = $this->findById($id);
if ($row === null) {
throw new \RuntimeException('Status nie istnieje.');
}
if ((int) $row['is_system'] === 1) {
throw new \RuntimeException('Statusów systemowych nie można edytować.');
}
$row = $this->getExistingById($id);
$this->assertEditable($row);
$statement = $this->db->prepare(
'UPDATE delivery_statuses
@@ -101,33 +94,12 @@ final class DeliveryStatusRepository
public function delete(int $id): void
{
$row = $this->findById($id);
if ($row === null) {
throw new \RuntimeException('Status nie istnieje.');
}
if ((int) $row['is_system'] === 1) {
throw new \RuntimeException('Statusów systemowych nie można usunąć.');
}
$row = $this->getExistingById($id);
$this->assertDeletable($row);
$key = (string) $row['key'];
$stmtMappings = $this->db->prepare(
'SELECT COUNT(*) FROM delivery_status_mappings WHERE normalized_status = :key'
);
$stmtMappings->bindValue(':key', $key);
$stmtMappings->execute();
$countMappings = (int) $stmtMappings->fetchColumn();
$stmtPackages = $this->db->prepare(
'SELECT COUNT(*) FROM shipment_packages WHERE delivery_status = :key'
);
$stmtPackages->bindValue(':key', $key);
$stmtPackages->execute();
$countPackages = (int) $stmtPackages->fetchColumn();
if ($countMappings > 0 || $countPackages > 0) {
throw new \RuntimeException('Status jest używany i nie może być usunięty.');
if ($this->isStatusUsed($key)) {
throw DeliveryStatusException::inUse();
}
$statement = $this->db->prepare(
@@ -154,4 +126,71 @@ final class DeliveryStatusRepository
return null;
}
private function assertKeyIsAvailable(string $key): void
{
if ($this->getByKey($key) !== null) {
throw DeliveryStatusException::duplicateKey();
}
}
/**
* @return array<string, mixed>
*/
private function getExistingById(int $id): array
{
$row = $this->findById($id);
if ($row === null) {
throw DeliveryStatusException::notFound();
}
return $row;
}
/**
* @param array<string, mixed> $row
*/
private function assertEditable(array $row): void
{
if ((int) $row['is_system'] === 1) {
throw DeliveryStatusException::systemStatusCannotBeEdited();
}
}
/**
* @param array<string, mixed> $row
*/
private function assertDeletable(array $row): void
{
if ((int) $row['is_system'] === 1) {
throw DeliveryStatusException::systemStatusCannotBeDeleted();
}
}
private function isStatusUsed(string $key): bool
{
return $this->countMappingsUsing($key) > 0 || $this->countPackagesUsing($key) > 0;
}
private function countMappingsUsing(string $key): int
{
$statement = $this->db->prepare(
'SELECT COUNT(*) FROM delivery_status_mappings WHERE normalized_status = :key'
);
$statement->bindValue(':key', $key);
$statement->execute();
return (int) $statement->fetchColumn();
}
private function countPackagesUsing(string $key): int
{
$statement = $this->db->prepare(
'SELECT COUNT(*) FROM shipment_packages WHERE delivery_status = :key'
);
$statement->bindValue(':key', $key);
$statement->execute();
return (int) $statement->fetchColumn();
}
}

View File

@@ -9,14 +9,15 @@ use Throwable;
final class OrdersStatisticsRepository
{
private static ?bool $hasOrdersTotalWithoutTax = null;
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;
private const DATE_START_SUFFIX = ' 00:00:00';
private const DATE_END_SUFFIX = ' 23:59:59';
private const SQL_IN_OPEN = ' IN (';
private const SQL_ROUND_OPEN = 'ROUND(';
private const SQL_IS_NOT_NULL = ' IS NOT NULL';
private const SQL_POSITIVE_CHECK = '%s IS NOT NULL AND %s > 0';
/** @var array<string, bool> */
private static array $hasOrdersColumns = [];
/** @var array<string, bool> */
private static array $hasOrderItemsColumns = [];
@@ -139,21 +140,17 @@ final class OrdersStatisticsRepository
'SELECT code
FROM order_statuses
WHERE is_active = 1
AND group_id IN (' . $inSql . ')
AND group_id' . self::SQL_IN_OPEN . $inSql . ')
ORDER BY sort_order ASC, id ASC'
);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
$rows = [];
}
$codes = [];
foreach ($rows as $code) {
foreach (is_array($rows) ? $rows : [] as $code) {
$normalized = strtolower(trim((string) $code));
if ($normalized !== '') {
$codes[] = $normalized;
@@ -196,15 +193,15 @@ final class OrdersStatisticsRepository
$sourceParams,
$channelParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
'date_from' => $dateFrom . self::DATE_START_SUFFIX,
'date_to' => $dateTo . self::DATE_END_SUFFIX,
]
);
$statusFilterSql = '';
if ($statusCodes !== []) {
[$statusInSql, $statusParams] = $this->buildStringInClause('st', $statusCodes);
$statusFilterSql = ' AND ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')';
$statusFilterSql = ' AND ' . $effectiveStatusSql . self::SQL_IN_OPEN . $statusInSql . ')';
$params = array_merge($params, $statusParams);
}
@@ -222,7 +219,7 @@ final class OrdersStatisticsRepository
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $channelSql . ' IN (' . $channelInSql . ')
AND ' . $channelSql . self::SQL_IN_OPEN . $channelInSql . ')
' . $statusFilterSql . '
GROUP BY DATE(' . $effectiveDateSql . '), ' . $channelSql . '
ORDER BY DATE(' . $effectiveDateSql . ') ASC';
@@ -232,15 +229,11 @@ final class OrdersStatisticsRepository
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
$rows = [];
}
$result = [];
foreach ($rows as $row) {
foreach (is_array($rows) ? $rows : [] as $row) {
$day = trim((string) ($row['day'] ?? ''));
$channelKey = trim((string) ($row['channel_key'] ?? ''));
if ($day === '' || $channelKey === '') {
@@ -291,15 +284,15 @@ final class OrdersStatisticsRepository
$sourceParams,
$channelParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
'date_from' => $dateFrom . self::DATE_START_SUFFIX,
'date_to' => $dateTo . self::DATE_END_SUFFIX,
]
);
$statusFilterSql = '';
if ($statusCodes !== []) {
[$statusInSql, $statusParams] = $this->buildStringInClause('st', $statusCodes);
$statusFilterSql = ' AND ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')';
$statusFilterSql = ' AND ' . $effectiveStatusSql . self::SQL_IN_OPEN . $statusInSql . ')';
$params = array_merge($params, $statusParams);
}
@@ -317,7 +310,7 @@ final class OrdersStatisticsRepository
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $channelSql . ' IN (' . $channelInSql . ')
AND ' . $channelSql . self::SQL_IN_OPEN . $channelInSql . ')
' . $statusFilterSql . '
GROUP BY ' . $monthSql . ', ' . $channelSql . '
ORDER BY ' . $monthSql . ' ASC';
@@ -327,15 +320,11 @@ final class OrdersStatisticsRepository
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
$rows = [];
}
$result = [];
foreach ($rows as $row) {
foreach (is_array($rows) ? $rows : [] as $row) {
$month = trim((string) ($row['month'] ?? ''));
$channelKey = trim((string) ($row['channel_key'] ?? ''));
if ($month === '' || $channelKey === '') {
@@ -388,8 +377,8 @@ final class OrdersStatisticsRepository
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to',
array_merge($sourceParams, [
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
'date_from' => $dateFrom . self::DATE_START_SUFFIX,
'date_to' => $dateTo . self::DATE_END_SUFFIX,
]),
$data['errors'],
'in_date_and_source'
@@ -404,12 +393,12 @@ final class OrdersStatisticsRepository
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $channelSql . ' IN (' . $channelInSql . ')',
AND ' . $channelSql . self::SQL_IN_OPEN . $channelInSql . ')',
array_merge(
$sourceParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
'date_from' => $dateFrom . self::DATE_START_SUFFIX,
'date_to' => $dateTo . self::DATE_END_SUFFIX,
],
$channelParams
),
@@ -432,12 +421,12 @@ final class OrdersStatisticsRepository
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')',
AND ' . $effectiveStatusSql . self::SQL_IN_OPEN . $statusInSql . ')',
array_merge(
$sourceParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
'date_from' => $dateFrom . self::DATE_START_SUFFIX,
'date_to' => $dateTo . self::DATE_END_SUFFIX,
],
$statusParams
),
@@ -479,7 +468,7 @@ final class OrdersStatisticsRepository
);
return [
'sql' => 'LOWER(COALESCE(' . $orderAlias . '.source, "")) IN (' . $sourceInSql . ')',
'sql' => 'LOWER(COALESCE(' . $orderAlias . '.source, ""))' . self::SQL_IN_OPEN . $sourceInSql . ')',
'params' => $sourceParams,
];
}
@@ -613,70 +602,76 @@ final class OrdersStatisticsRepository
private function netAmountSql(string $orderAlias): string
{
$netColumn = null;
if ($this->hasOrdersColumn('total_without_tax')) {
$netColumn = $orderAlias . '.total_without_tax';
} elseif ($this->hasOrdersColumn('total_net')) {
$netColumn = $orderAlias . '.total_net';
}
$grossColumn = null;
if ($this->hasOrdersColumn('total_with_tax')) {
$grossColumn = $orderAlias . '.total_with_tax';
} elseif ($this->hasOrdersColumn('total_gross')) {
$grossColumn = $orderAlias . '.total_gross';
}
$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 . '
' . $fallbackSql . '
ELSE 0
END';
}
$netColumn = $this->firstOrdersColumn($orderAlias, ['total_without_tax', 'total_net']);
$grossColumn = $this->firstOrdersColumn($orderAlias, ['total_with_tax', 'total_gross']);
$itemWithDeliverySql = $this->itemWithDeliveryAmountSql($orderAlias);
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)';
return $this->netColumnAmountSql($netColumn, $grossColumn, $itemWithDeliverySql);
}
if ($grossColumn !== null) {
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 $this->grossColumnNetFallbackSql($grossColumn, $itemWithDeliverySql);
}
return $legacyGrossSql;
return $itemWithDeliverySql !== null ? $this->coalesceZeroSql($itemWithDeliverySql) : '0';
}
private function netColumnAmountSql(string $netColumn, ?string $grossColumn, ?string $itemWithDeliverySql): string
{
if ($grossColumn !== null) {
return $this->netColumnWithGrossFallbackSql($netColumn, $grossColumn, $itemWithDeliverySql);
}
if ($itemWithDeliverySql !== null) {
return 'COALESCE(' . $itemWithDeliverySql . ', 0)';
return $this->caseSql([
$this->whenSql($this->positiveSql($netColumn), $netColumn),
$this->whenSql($this->notNullSql($itemWithDeliverySql), $itemWithDeliverySql),
]);
}
return '0';
return $this->coalesceZeroSql($netColumn);
}
private function netColumnWithGrossFallbackSql(
string $netColumn,
string $grossColumn,
?string $itemWithDeliverySql
): string {
$cases = [
$this->whenSql($this->positiveSql($netColumn), $netColumn),
];
if ($itemWithDeliverySql !== null) {
$cases[] = $this->whenSql($this->notNullSql($itemWithDeliverySql), $itemWithDeliverySql);
}
$cases[] = $this->whenSql($this->positiveSql($grossColumn), $this->legacyGrossNetSql($grossColumn));
return $this->caseSql($cases);
}
private function grossColumnNetFallbackSql(string $grossColumn, ?string $itemWithDeliverySql): string
{
if ($itemWithDeliverySql !== null) {
return $this->caseSql([
$this->whenSql($this->notNullSql($itemWithDeliverySql), $itemWithDeliverySql),
$this->whenSql($this->positiveSql($grossColumn), $this->legacyGrossNetSql($grossColumn)),
]);
}
return $this->legacyGrossNetSql($grossColumn);
}
private function itemWithDeliveryAmountSql(string $orderAlias): ?string
{
$itemNetSql = $this->itemNetAmountSql($orderAlias);
return $itemNetSql === null ? null : '((' . $itemNetSql . ') + ' . $this->deliveryNetAmountSql($orderAlias) . ')';
}
private function legacyGrossNetSql(string $grossColumn): string
{
return self::SQL_ROUND_OPEN . $this->coalesceZeroSql($grossColumn) . ' / 1.23, 2)';
}
private function itemNetAmountSql(string $orderAlias): ?string
@@ -699,12 +694,16 @@ final class OrdersStatisticsRepository
$lineCases = [];
if ($netColumn !== null) {
$lineCases[] = 'WHEN ' . $netColumn . ' IS NOT NULL AND ' . $netColumn . ' > 0
THEN ROUND(' . $netColumn . ' * ' . $quantitySql . ', 2)';
$lineCases[] = $this->whenSql(
$this->positiveSql($netColumn),
self::SQL_ROUND_OPEN . $netColumn . ' * ' . $quantitySql . ', 2)'
);
}
if ($grossColumn !== null) {
$lineCases[] = 'WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0
THEN ROUND((' . $grossColumn . ' * ' . $quantitySql . ') / (1 + (' . $vatSql . ' / 100)), 2)';
$lineCases[] = $this->whenSql(
$this->positiveSql($grossColumn),
self::SQL_ROUND_OPEN . '(' . $grossColumn . ' * ' . $quantitySql . ') / (1 + (' . $vatSql . ' / 100)), 2)'
);
}
return 'SELECT SUM(CASE
@@ -729,6 +728,20 @@ final class OrdersStatisticsRepository
return null;
}
/**
* @param list<string> $columns
*/
private function firstOrdersColumn(string $orderAlias, array $columns): ?string
{
foreach ($columns as $column) {
if ($this->hasOrdersColumn($column)) {
return $orderAlias . '.' . $column;
}
}
return null;
}
private function deliveryNetAmountSql(string $orderAlias): string
{
if (!$this->hasOrdersColumn('delivery_price')) {
@@ -736,71 +749,73 @@ final class OrdersStatisticsRepository
}
return 'CASE
WHEN ' . $orderAlias . '.delivery_price IS NOT NULL AND ' . $orderAlias . '.delivery_price > 0
THEN ROUND(' . $orderAlias . '.delivery_price / 1.23, 2)
' . $this->whenSql(
$this->positiveSql($orderAlias . '.delivery_price'),
self::SQL_ROUND_OPEN . $orderAlias . '.delivery_price / 1.23, 2)'
) . '
ELSE 0
END';
}
/**
* @param list<string> $cases
*/
private function caseSql(array $cases): string
{
return 'CASE
' . implode("\n ", $cases) . '
ELSE 0
END';
}
private function whenSql(string $condition, string $resultSql): string
{
return 'WHEN ' . $condition . ' THEN ' . $resultSql;
}
private function positiveSql(string $valueSql): string
{
return sprintf(self::SQL_POSITIVE_CHECK, $valueSql, $valueSql);
}
private function notNullSql(string $valueSql): string
{
return $valueSql . self::SQL_IS_NOT_NULL;
}
private function coalesceZeroSql(string $valueSql): string
{
return 'COALESCE(' . $valueSql . ', 0)';
}
private function grossAmountSql(string $orderAlias): string
{
if ($this->hasOrdersColumn('total_with_tax')) {
return 'COALESCE(' . $orderAlias . '.total_with_tax, 0)';
}
$column = $this->firstOrdersColumn($orderAlias, [
'total_with_tax',
'total_gross',
'total_without_tax',
'total_net',
]);
if ($this->hasOrdersColumn('total_gross')) {
return 'COALESCE(' . $orderAlias . '.total_gross, 0)';
}
if ($this->hasOrdersColumn('total_without_tax')) {
return 'COALESCE(' . $orderAlias . '.total_without_tax, 0)';
}
if ($this->hasOrdersColumn('total_net')) {
return 'COALESCE(' . $orderAlias . '.total_net, 0)';
}
return '0';
return $column === null ? '0' : $this->coalesceZeroSql($column);
}
private function rawStatusSql(string $orderAlias): string
{
if ($this->hasOrdersColumn('status_code')) {
return 'COALESCE(' . $orderAlias . '.status_code, "")';
$column = $this->firstOrdersColumn($orderAlias, ['status_code', 'external_status_id']);
return $column === null ? '""' : $this->coalesceEmptySql($column);
}
if ($this->hasOrdersColumn('external_status_id')) {
return 'COALESCE(' . $orderAlias . '.external_status_id, "")';
}
return '""';
private function coalesceEmptySql(string $valueSql): string
{
return 'COALESCE(' . $valueSql . ', "")';
}
private function hasOrdersColumn(string $column): bool
{
if ($column === 'integration_id' && self::$hasOrdersIntegrationId !== null) {
return self::$hasOrdersIntegrationId;
}
if ($column === 'total_without_tax' && self::$hasOrdersTotalWithoutTax !== null) {
return self::$hasOrdersTotalWithoutTax;
}
if ($column === 'total_net' && self::$hasOrdersTotalNet !== null) {
return self::$hasOrdersTotalNet;
}
if ($column === 'total_with_tax' && self::$hasOrdersTotalWithTax !== null) {
return self::$hasOrdersTotalWithTax;
}
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;
}
if ($column === 'external_status_id' && self::$hasOrdersExternalStatusId !== null) {
return self::$hasOrdersExternalStatusId;
if (array_key_exists($column, self::$hasOrdersColumns)) {
return self::$hasOrdersColumns[$column];
}
try {
@@ -809,30 +824,7 @@ final class OrdersStatisticsRepository
$exists = false;
}
if ($column === 'total_without_tax') {
self::$hasOrdersTotalWithoutTax = $exists;
}
if ($column === 'total_net') {
self::$hasOrdersTotalNet = $exists;
}
if ($column === 'integration_id') {
self::$hasOrdersIntegrationId = $exists;
}
if ($column === 'total_with_tax') {
self::$hasOrdersTotalWithTax = $exists;
}
if ($column === 'total_gross') {
self::$hasOrdersTotalGross = $exists;
}
if ($column === 'delivery_price') {
self::$hasOrdersDeliveryPrice = $exists;
}
if ($column === 'status_code') {
self::$hasOrdersStatusCode = $exists;
}
if ($column === 'external_status_id') {
self::$hasOrdersExternalStatusId = $exists;
}
self::$hasOrdersColumns[$column] = $exists;
return $exists;
}
@@ -862,6 +854,14 @@ final class OrdersStatisticsRepository
private function detectTableColumn(string $table, string $column): bool
{
if ($this->isSqlite()) {
return $this->sqliteTableHasColumn($table, $column);
}
return $this->mysqlTableHasColumn($table, $column);
}
private function sqliteTableHasColumn(string $table, string $column): bool
{
$stmt = $this->pdo->query('PRAGMA table_info(' . $table . ')');
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if (!is_array($rows)) {
@@ -877,6 +877,8 @@ final class OrdersStatisticsRepository
return false;
}
private function mysqlTableHasColumn(string $table, string $column): bool
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM information_schema.COLUMNS

View File

@@ -221,20 +221,9 @@ final class OrdersStatisticsRepositoryTest extends TestCase
private function resetColumnCache(): void
{
foreach ([
'hasOrdersTotalWithoutTax',
'hasOrdersTotalNet',
'hasOrdersTotalWithTax',
'hasOrdersTotalGross',
'hasOrdersDeliveryPrice',
'hasOrdersIntegrationId',
'hasOrdersStatusCode',
'hasOrdersExternalStatusId',
] as $propertyName) {
$property = new ReflectionProperty(OrdersStatisticsRepository::class, $propertyName);
$property = new ReflectionProperty(OrdersStatisticsRepository::class, 'hasOrdersColumns');
$property->setAccessible(true);
$property->setValue(null, null);
}
$property->setValue(null, []);
$property = new ReflectionProperty(OrdersStatisticsRepository::class, 'hasOrderItemsColumns');
$property->setAccessible(true);