feat(11-12-accounting): phases 11-12 complete — milestone v0.3 done

Phase 11: Receipt preview, print & PDF via dompdf.
Phase 12: Accounting section with receipt list, filters, pagination,
selectable checkboxes and XLSX export via PhpSpreadsheet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:00:29 +01:00
parent fb60b6d5d7
commit 22fc330055
12 changed files with 1007 additions and 18 deletions

View File

@@ -16,7 +16,7 @@ Moduł księgowości z obsługą paragonów: wielokonfiguracyjne szablony, wysta
| 9 | Konfiguracja paragonów (Ustawienia) | 1/1 | Complete ✓ |
| 10 | Wystawianie paragonów z zamówienia | 1/1 | Complete ✓ |
| 11 | Podgląd i wydruk paragonu (HTML+PDF) | 1/1 | Complete ✓ |
| 12 | Sekcja Księgowość — lista + eksport XLSX | 1 | Not started |
| 12 | Sekcja Księgowość — lista + eksport XLSX | 1/1 | Complete ✓ |
## Completed Milestones

View File

@@ -5,31 +5,32 @@
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
**Current focus:** Milestone v0.3 Moduł Paragonów — Faza 11 COMPLETE, transition do fazy 12.
**Current focus:** Milestone v0.3 Moduł Paragonów — COMPLETE. Transition + milestone completion.
## Current Position
Milestone: v0.3 Moduł Paragonów
Phase: 11 of 12 (11-receipt-print) — Complete
Plan: 11-01 complete
Status: Loop closed, phase transition required
Last activity: 2026-03-15 — Phase 11 complete (podgląd, druk, PDF paragonu)
Milestone: v0.3 Moduł Paragonów — COMPLETE
Phase: 12 of 12 (12-accounting-list) — Complete
Plan: 12-01 complete
Status: Milestone v0.3 complete, transition required
Last activity: 2026-03-15 — Phase 12 complete (lista paragonów + eksport XLSX)
Progress:
- v0.1 Initial Release: [██████████] 100% ✓
- v0.2 Pre-Expansion Fixes: [██████████] 100% ✓
- v0.3 Moduł Paragonów: [████████░░] 80%
- v0.3 Moduł Paragonów: [██████████] 100% ✓
- Phase 8: [██████████] 100% ✓
- Phase 9: [██████████] 100% ✓
- Phase 10: [██████████] 100% ✓
- Phase 11: [██████████] 100% ✓
- Phase 12: [██████████] 100% ✓
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — phase transition required]
✓ ✓ ✓ [Loop complete — milestone v0.3 COMPLETE]
```
## Accumulated Context
@@ -52,6 +53,13 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 2026-03-15 | Moduł Accounting w App\Modules\Accounting | Faza 10 | Separacja od Settings |
| 2026-03-15 | dompdf v3.1 server-side PDF generation | Faza 11 | Nowa zależność composer; wymaga vendor/ na serwerze |
| 2026-03-15 | ftp-kr vendor/ nie ignorowany (zmiana na /vendor/bin) | Faza 11 | Automatyczny upload vendor/ przy zmianach; rewizja decyzji z fazy 07 |
| 2026-03-15 | PhpSpreadsheet v5.5 dla eksportu XLSX | Faza 12 | Nowa zależność composer; XLSX lepszy od CSV dla księgowości |
| 2026-03-15 | POST eksport z CSRF + dwa tryby (zaznaczone/wszystkie z filtra) | Faza 12 | Bezpieczny eksport; selectable table-list reuse |
### Skill Audit (Faza 12, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym UNIFY |
### Skill Audit (Faza 11, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
@@ -132,7 +140,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- **Delivery mapping "Szukaj..." layout** — JS `attachSelectFilter()` w allegro.php tworzy input search dla InPost/Apaczka selectów, wizualnie wygląda jakby należał do wiersza powyżej. Pre-existing bug, do naprawy osobno.
### Git State
Last commit: ed057fc (feat(08-10-receipt-module): phases 08-10 complete)
Last commit: fb60b6d (feat(11-receipt-print): phase 11 complete — receipt preview, print & PDF)
Branch: main
Feature branches merged: none
@@ -142,13 +150,13 @@ Brak.
## Session Continuity
Last session: 2026-03-15
Stopped at: Phase 11 complete, transition required
Next action: Phase transition — git commit, ROADMAP update, then /paul:plan for phase 12
Resume file: .paul/phases/11-receipt-print/11-01-SUMMARY.md
Stopped at: Milestone v0.3 COMPLETE — transition required
Next action: Git commit for phase 12, then /paul:complete-milestone v0.3
Resume file: .paul/phases/12-accounting-list/12-01-SUMMARY.md
Resume context:
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
- v0.3: IN PROGRESS — Phase 08-11 done, Phase 12 next
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
- Faza 0 (nieaktywne przyciski) zrobiona poza planem
---

View File

@@ -0,0 +1,300 @@
---
phase: 12-accounting-list
plan: 01
type: execute
wave: 1
depends_on: ["11-01"]
files_modified:
- src/Modules/Accounting/AccountingController.php
- src/Modules/Accounting/ReceiptRepository.php
- resources/views/accounting/index.php
- resources/views/layouts/app.php
- routes/web.php
- resources/lang/pl.php
- resources/scss/app.scss
- public/assets/css/app.css
- composer.json
- DOCS/ARCHITECTURE.md
autonomous: false
---
<objective>
## Goal
Sekcja Ksiegowosc w nawigacji glownej z lista wszystkich paragonow, filtrami, paginacja i eksportem do XLSX.
## Purpose
Dotychczas paragony widoczne sa tylko w kontekscie pojedynczego zamowienia. Uzytkownik potrzebuje przegladac wszystkie paragony w jednym miejscu — filtrowac po dacie, konfiguracji, numerze — i eksportowac do XLSX dla celów ksiegowych.
## Output
- Nowy AccountingController z metoda index() i export()
- Widok listy paragonow z filtrami (reuse komponentu table-list)
- Eksport XLSX przez PhpSpreadsheet
- Nowy link "Ksiegowosc" w nawigacji glownej (sidebar)
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/11-receipt-print/11-01-SUMMARY.md — podglad, druk, PDF paragonu
@.paul/phases/10-receipt-issue/10-01-SUMMARY.md — wystawianie paragonow, ReceiptRepository
## Source Files
@src/Modules/Accounting/ReceiptRepository.php
@src/Modules/Accounting/ReceiptController.php
@resources/views/components/table-list.php
@resources/views/orders/list.php — wzorzec listy z filtrami
@src/Modules/Orders/OrdersController.php — wzorzec metody index() z tableList
@resources/views/layouts/app.php — sidebar navigation
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
## Skill Invocation Checklist
- [ ] sonar-scanner uruchomiony po APPLY
</skills>
<acceptance_criteria>
## AC-1: Link Ksiegowosc w nawigacji glownej
```gherkin
Given uzytkownik jest zalogowany
When widzi sidebar nawigacji
Then widoczny jest nowy link "Ksiegowosc" prowadzacy do /accounting
And link jest aktywny gdy uzytkownik jest na stronie /accounting
```
## AC-2: Lista paragonow z paginacja
```gherkin
Given istnieja paragony w bazie danych
When uzytkownik otwiera /accounting
Then wyswietlana jest tabela paragonow z kolumnami:
- Numer paragonu, Data wystawienia, Data sprzedazy, Kwota brutto, Konfiguracja, Zamowienie
And tabela jest paginowana (domyslnie 20 na strone)
And mozna sortowac po: numer, data wystawienia, kwota brutto
```
## AC-3: Filtry listy
```gherkin
Given uzytkownik jest na stronie /accounting
When uzywa filtrow
Then moze filtrowac po:
- Szukaj (numer paragonu, numer zamowienia)
- Konfiguracja (select z aktywnych konfiguracji)
- Data wystawienia od / do
And filtry sa zachowane w URL (query string)
```
## AC-4: Eksport XLSX
```gherkin
Given uzytkownik jest na stronie /accounting z zastosowanymi filtrami
When klika przycisk "Eksportuj XLSX"
Then przeglądarka pobiera plik .xlsx z lista paragonow
And plik zawiera te same kolumny co tabela + dodatkowe: data sprzedazy, nr referencyjny
And eksport uwzglednia aktywne filtry (nie eksportuje wszystkiego)
And plik ma nazwe: paragony_YYYY-MM-DD.xlsx
```
## AC-5: Pusta lista
```gherkin
Given brak paragonow spelniajacych kryteria (pusta baza lub filtr bez wynikow)
When uzytkownik otwiera /accounting
Then wyswietlany jest komunikat "Brak paragonow"
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: ReceiptRepository — metoda paginate() i exportData()</name>
<files>
src/Modules/Accounting/ReceiptRepository.php
</files>
<action>
1. **paginate(array $filters): array** — analogicznie do OrdersRepository::paginate()
- Przyjmuje filtry: search, config_id, date_from, date_to, sort, sort_dir, page, per_page
- SELECT r.*, rc.name AS config_name, o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
- WHERE dynamiczne na podstawie filtrow:
- search: LIKE na receipt_number i o.internal_order_number i o.external_order_id
- config_id: = config_id
- date_from: issue_date >= date_from
- date_to: issue_date <= date_to
- ORDER BY dynamiczne (whitelist: receipt_number, issue_date, total_gross) + sort_dir (ASC/DESC)
- LIMIT/OFFSET na podstawie page i per_page
- Zwraca: ['items' => [...], 'total' => int, 'page' => int, 'per_page' => int]
- Uzyj COUNT(*) osobnym zapytaniem dla total
2. **exportData(array $filters): array** — ta sama logika WHERE co paginate, ale BEZ LIMIT/OFFSET
- Zwraca flat array wierszy z tymi samymi kolumnami
- Uzywany przez export XLSX
Wzorzec: prepared statements, whitelist dla sort, max per_page = 100.
</action>
<verify>
- `php -l src/Modules/Accounting/ReceiptRepository.php`
</verify>
<done>AC-2 backend, AC-3 backend, AC-4 backend</done>
</task>
<task type="auto">
<name>Task 2: AccountingController + trasy + nawigacja + widok</name>
<files>
src/Modules/Accounting/AccountingController.php,
routes/web.php,
resources/views/accounting/index.php,
resources/views/layouts/app.php,
resources/lang/pl.php,
composer.json
</files>
<action>
1. **Instalacja PhpSpreadsheet:**
- `php composer.phar require phpoffice/phpspreadsheet --ignore-platform-reqs`
2. **AccountingController** (nowy plik w src/Modules/Accounting/):
- Konstruktor: Template, Translator, AuthService, ReceiptRepository, ReceiptConfigRepository
- **index(Request): Response** — GET /accounting
- Parsuj filtry z request (search, config_id, date_from, date_to, sort, sort_dir, page, per_page)
- Wywolaj ReceiptRepository::paginate($filters)
- Pobierz liste aktywnych konfiguracji (ReceiptConfigRepository::listAll() filtruj is_active)
- Mapuj wiersze do formatu tableList (analogicznie do OrdersController)
- Renderuj widok accounting/index z tableList data
- **export(Request): Response** — GET /accounting/export
- Te same filtry co index
- Wywolaj ReceiptRepository::exportData($filters)
- Uzyj PhpSpreadsheet: nowy Spreadsheet, ustaw naglowki, wypelnij wiersze
- Kolumny: Numer, Data wystawienia, Data sprzedazy, Kwota brutto, Konfiguracja, Nr zamowienia, Nr referencyjny
- Zwroc Response z Content-Type xlsx i Content-Disposition attachment
- Nazwa pliku: paragony_YYYY-MM-DD.xlsx
- UWAGA: PhpSpreadsheet zapisuje do php://output — uzyj ob_start/ob_get_clean
3. **routes/web.php:**
- Dodaj instancje AccountingController (reuse receiptRepository, receiptConfigRepository)
- `GET /accounting` → [$accountingController, 'index'], [$authMiddleware]
- `GET /accounting/export` → [$accountingController, 'export'], [$authMiddleware]
- Dodaj PRZED trasami receipt (logicznie: sekcja accounting)
4. **resources/views/accounting/index.php:**
- Uzyj komponentu table-list (include components/table-list.php z $tableList)
- Dodaj przycisk "Eksportuj XLSX" w naglowku (link do /accounting/export z aktualnymi filtrami w query string)
- Wzorzec: analogicznie do orders/list.php
5. **resources/views/layouts/app.php:**
- Dodaj nowa sekcje w sidebar PRZED "Ustawienia":
```php
<a class="sidebar__link<?= $currentMenu === 'accounting' ? ' is-active' : '' ?>" href="/accounting">
<svg>...</svg> <?= $e($t('navigation.accounting_section')) ?>
</a>
```
- Uzyj ikony dokumentu/receipt (inline SVG)
6. **resources/lang/pl.php:**
- Dodaj klucze: `navigation.accounting_section` => 'Ksiegowosc'
- `accounting.title`, `accounting.export`, `accounting.empty`
- `accounting.filters.*`: search, config, date_from, date_to, any
- `accounting.columns.*`: number, issue_date, sale_date, total_gross, config, order
Wzorzec: Reuse komponentu table-list.php, analogia do OrdersController::index().
</action>
<verify>
- `php -l src/Modules/Accounting/AccountingController.php`
- `php -l routes/web.php`
- `php -l resources/views/accounting/index.php`
- `php -l resources/views/layouts/app.php`
- `composer show phpoffice/phpspreadsheet` — zainstalowany
</verify>
<done>AC-1, AC-2, AC-3, AC-4, AC-5 spelnione</done>
</task>
<task type="auto">
<name>Task 3: SCSS build + aktualizacja dokumentacji</name>
<files>
resources/scss/app.scss,
public/assets/css/app.css,
DOCS/ARCHITECTURE.md
</files>
<action>
1. Build SCSS do CSS (jesli nowe klasy dodane)
2. Zaktualizuj DOCS/ARCHITECTURE.md:
- Dodaj AccountingController (index, export) do sekcji Modules/Accounting
- Dodaj ReceiptRepository::paginate(), exportData()
- Dodaj trasy /accounting i /accounting/export
</action>
<verify>
- `npx sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css`
- Brak bledow
</verify>
<done>Dokumentacja zaktualizowana, CSS zbudowany</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Sekcja Ksiegowosc: lista paragonow z filtrami, paginacja, eksport XLSX, link w nawigacji</what-built>
<how-to-verify>
1. Otworz aplikacje → sidebar → kliknij "Ksiegowosc"
2. Sprawdz: tabela paragonow wyswietla sie z kolumnami
3. Uzyj filtrow: szukaj po numerze, wybierz konfiguracje, ustaw daty
4. Sprawdz paginacje: zmien strone, zmien ilosc na strone
5. Kliknij "Eksportuj XLSX" — plik pobiera sie
6. Otworz XLSX — dane poprawne, filtry uwzglednione
7. Sprawdz czy link do zamowienia w tabeli dziala
8. Sprawdz pusta liste (filtr bez wynikow) — komunikat "Brak paragonow"
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- database/migrations/* (schemat zablokowany)
- src/Modules/Settings/ReceiptConfigRepository.php (gotowe z fazy 09)
- src/Modules/Accounting/ReceiptController.php (gotowe z fazy 11 — tylko odczyt)
- resources/views/receipts/* (gotowe z fazy 11)
## SCOPE LIMITS
- Brak edycji/anulowania paragonu — poza zakresem v0.3
- Brak zaawansowanych raportow (sumy per konfiguracja, wykresy) — future
- Brak eksportu CSV — tylko XLSX
- Brak filtrowania po nabywcy — uproszczenie v0.3
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `composer show phpoffice/phpspreadsheet` — zainstalowany
- [ ] `php -l src/Modules/Accounting/AccountingController.php`
- [ ] `php -l src/Modules/Accounting/ReceiptRepository.php`
- [ ] `php -l resources/views/accounting/index.php`
- [ ] `php -l routes/web.php`
- [ ] Link "Ksiegowosc" widoczny w sidebar nawigacji
- [ ] Lista paragonow wyswietla sie z paginacja
- [ ] Filtry dzialaja (search, config, date_from, date_to)
- [ ] Sortowanie dziala (numer, data, kwota)
- [ ] Eksport XLSX pobiera sie i zawiera poprawne dane
- [ ] Pusta lista wyswietla komunikat
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Wszystkie 3 taski auto + 1 checkpoint ukonczone
- Wszystkie AC-1 do AC-5 spelnione
- PhpSpreadsheet zainstalowany i dzialajacy
- Brak bledow PHP
- Weryfikacja manualna przez uzytkownika (checkpoint)
</success_criteria>
<output>
After completion, create `.paul/phases/12-accounting-list/12-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,143 @@
---
phase: 12-accounting-list
plan: 01
subsystem: accounting
tags: [phpspreadsheet, xlsx, export, list, pagination, filters, selectable]
requires:
- phase: 11-receipt-print
provides: ReceiptController show/print/pdf, receipt views
- phase: 10-receipt-issue
provides: ReceiptRepository, receipts table, snapshots
provides:
- Accounting section in main navigation
- Receipt list with filters, pagination, sorting
- XLSX export (selected or all from filter)
- Selectable rows with checkboxes
affects: []
tech-stack:
added: [phpoffice/phpspreadsheet ^5.5]
patterns: [selectable table-list with POST export, findByIds batch query]
key-files:
created:
- src/Modules/Accounting/AccountingController.php
- resources/views/accounting/index.php
modified:
- src/Modules/Accounting/ReceiptRepository.php
- routes/web.php
- resources/views/layouts/app.php
- resources/lang/pl.php
- composer.json
- DOCS/ARCHITECTURE.md
key-decisions:
- "PhpSpreadsheet v5.5 for XLSX export (not CSV)"
- "POST export with CSRF instead of GET (selected IDs via form)"
- "Selectable table-list reuse — built-in component feature enabled"
- "Two export modes: selected IDs or all matching current filter"
patterns-established:
- "Selectable table-list pattern: selectable: true + JS form submit"
- "findByIds() batch query pattern for selected records"
duration: ~40min
completed: 2026-03-15
---
# Phase 12 Plan 01: Accounting Section — Receipt List & XLSX Export Summary
**Sekcja Księgowość w nawigacji głównej z listą paragonów, filtrami, paginacją, zaznaczaniem i eksportem XLSX.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~40min |
| Completed | 2026-03-15 |
| Tasks | 4 completed (3 auto + 1 checkpoint) |
| Files modified | 10 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Link Księgowość w nawigacji głównej | Pass | Osobny link w sidebar z ikoną dokumentu, przed Ustawienia |
| AC-2: Lista paragonów z paginacją | Pass | Reuse table-list, sortowanie po numerze/dacie/kwocie |
| AC-3: Filtry listy | Pass | Szukaj, konfiguracja (select), data od/do — zachowane w URL |
| AC-4: Eksport XLSX | Pass | Dwa tryby: zaznaczone (POST + selected_ids) i wszystkie z filtra |
| AC-5: Pusta lista | Pass | Komunikat "Brak paragonów" |
## Accomplishments
- AccountingController z index() i export() — pełna sekcja księgowości
- ReceiptRepository: paginate(), exportData(), findByIds() — 3 nowe metody
- Zaznaczanie paragonów checkboxami (reuse wbudowanego selectable w table-list)
- Eksport XLSX z PhpSpreadsheet: zaznaczone lub wszystkie z filtra
- Link "Księgowość" w sidebar nawigacji
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Accounting/AccountingController.php` | Created | index() lista + export() XLSX |
| `src/Modules/Accounting/ReceiptRepository.php` | Modified | +paginate(), +exportData(), +findByIds() |
| `routes/web.php` | Modified | +AccountingController, GET /accounting, POST /accounting/export |
| `resources/views/accounting/index.php` | Created | Lista z przyciskami eksportu + JS selection |
| `resources/views/layouts/app.php` | Modified | +link Księgowość w sidebar |
| `resources/lang/pl.php` | Modified | +accounting.*, +navigation.accounting_section |
| `composer.json` | Modified | +phpoffice/phpspreadsheet ^5.5 |
| `public/assets/css/app.css` | Modified | Rebuild |
| `DOCS/ARCHITECTURE.md` | Modified | +AccountingController, +trasy, +metody repo |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| PhpSpreadsheet (nie CSV) | XLSX lepszy dla księgowości — formatowanie, polskie znaki | Nowa zależność composer |
| POST eksport z CSRF | Wysyłanie selected_ids[] wymaga POST; bezpieczeństwo | Formularz ukryty + JS submit |
| Dwa tryby eksportu | User request: zaznaczanie + eksport wszystkich z filtra | findByIds() + exportData() |
| Selectable table-list reuse | Komponent już ma wbudowane checkboxy | Zero nowego kodu w komponencie |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope additions | 1 | Zaznaczanie paragonów (user request during checkpoint) |
| Auto-fixed | 0 | — |
**Total impact:** Pozytywne rozszerzenie — zaznaczanie paragonów do eksportu na prośbę użytkownika.
### Scope Additions
**1. Zaznaczanie paragonów do eksportu**
- **Requested during:** Checkpoint (Task 4)
- **What:** Checkboxy, "Eksportuj zaznaczone", "Eksportuj wszystkie (filtr)"
- **Implementation:** selectable: true w table-list, POST export, findByIds(), JS form
- **Impact:** Lepsza kontrola użytkownika nad eksportem
### Skill Audit
| Expected | Invoked | Notes |
|----------|---------|-------|
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym UNIFY |
## Next Phase Readiness
**Ready:**
- Milestone v0.3 Moduł Paragonów COMPLETE
- Pełny cykl: konfiguracja → wystawienie → podgląd → druk → PDF → lista → eksport
**Concerns:**
- sonar-scanner nie uruchomiony (gap z wielu faz)
- vendor/ musi być uploadowany ręcznie na serwer po każdej zmianie zależności
**Blockers:**
- Brak
---
*Phase: 12-accounting-list, Plan: 01*
*Completed: 2026-03-15*

View File

@@ -9,7 +9,7 @@
- `App\Modules\Orders`
- `App\Modules\Users`
- `App\Modules\Settings`
- `App\Modules\Accounting` (modul paragonow — wystawianie z zamowien)
- `App\Modules\Accounting` (modul paragonow — wystawianie, podglad, druk, PDF, lista, eksport XLSX)
## Routing
- `GET /login`, `POST /login`, `POST /logout`
@@ -18,6 +18,8 @@
- `GET /orders/list`
- `GET /orders/{id}`
- `POST /orders/{id}/status`
- `GET /accounting` (lista paragonow z filtrami i paginacja)
- `GET /accounting/export` (eksport XLSX z aktywnymi filtrami)
- `GET /users` (redirect do `/settings/users`)
- `POST /users` (compat route)
- `GET /settings` (redirect do `/settings/users`)
@@ -118,8 +120,9 @@
- `App\Modules\Settings\AllegroStatusSyncService`
- `App\Modules\Settings\ReceiptConfigController`
- `App\Modules\Settings\ReceiptConfigRepository`
- `App\Modules\Accounting\ReceiptRepository`
- `App\Modules\Accounting\ReceiptController`
- `App\Modules\Accounting\ReceiptRepository` (findById, findByOrderId, create, getNextNumber, paginate, exportData)
- `App\Modules\Accounting\ReceiptController` (create, store, show, printView, pdf)
- `App\Modules\Accounting\AccountingController` (index — lista paragonow, export — XLSX)
- `App\Modules\Shipments\ShipmentProviderInterface`
- `App\Modules\Shipments\ShipmentProviderRegistry`
- `App\Modules\Shipments\ApaczkaShipmentService`

View File

@@ -5,7 +5,8 @@
"license": "proprietary",
"require": {
"php": "^8.4",
"dompdf/dompdf": "^3.1"
"dompdf/dompdf": "^3.1",
"phpoffice/phpspreadsheet": "^5.5"
},
"require-dev": {
"phpunit/phpunit": "^11.5",

View File

@@ -34,6 +34,7 @@ return [
'inpost' => 'Integracja InPost',
'company' => 'Dane firmy',
'accounting' => 'Ksiegowosc',
'accounting_section' => 'Ksiegowosc',
],
'marketplace' => [
'title' => 'Marketplace',
@@ -1258,4 +1259,26 @@ return [
'empty' => 'Brak dokumentow',
],
],
'accounting' => [
'title' => 'Ksiegowosc — Paragony',
'export' => 'Eksportuj XLSX',
'export_selected' => 'Eksportuj zaznaczone',
'export_all' => 'Eksportuj wszystkie (filtr)',
'empty' => 'Brak paragonow',
'filters' => [
'search' => 'Szukaj (numer paragonu, zamowienia)',
'config' => 'Konfiguracja',
'date_from' => 'Data od',
'date_to' => 'Data do',
'any' => 'Wszystkie',
],
'columns' => [
'number' => 'Numer paragonu',
'issue_date' => 'Data wystawienia',
'sale_date' => 'Data sprzedazy',
'total_gross' => 'Kwota brutto',
'config' => 'Konfiguracja',
'order' => 'Zamowienie',
],
],
];

View File

@@ -0,0 +1,99 @@
<?php
$exportQueryVal = (string) ($exportQuery ?? '');
$csrfTokenVal = (string) ($csrfToken ?? '');
$queryData = is_array($tableList['query'] ?? null) ? $tableList['query'] : [];
?>
<section class="card">
<div class="orders-head">
<div>
<h2 class="section-title"><?= $e($t('accounting.title')) ?></h2>
</div>
<div class="accounting-export-actions">
<span class="muted js-accounting-selection-info"></span>
<button type="button" class="btn btn--secondary js-accounting-export-selected" disabled><?= $e($t('accounting.export_selected')) ?></button>
<button type="button" class="btn btn--primary js-accounting-export-all"><?= $e($t('accounting.export_all')) ?></button>
</div>
</div>
</section>
<form id="accountingExportForm" method="post" action="/accounting/export" style="display:none;">
<input type="hidden" name="_token" value="<?= $e($csrfTokenVal) ?>">
<input type="hidden" name="export_all" value="0" id="exportAllFlag">
<?php foreach ($queryData as $key => $value): ?>
<?php if ($value !== '' && $value !== null && $key !== 'page' && $key !== 'per_page' && $key !== 'sort' && $key !== 'sort_dir'): ?>
<input type="hidden" name="<?= $e((string) $key) ?>" value="<?= $e((string) $value) ?>">
<?php endif; ?>
<?php endforeach; ?>
<div id="exportSelectedIds"></div>
</form>
<?php require __DIR__ . '/../components/table-list.php'; ?>
<script>
(function() {
var form = document.getElementById('accountingExportForm');
var exportAllFlag = document.getElementById('exportAllFlag');
var exportSelectedIds = document.getElementById('exportSelectedIds');
var exportSelectedBtn = document.querySelector('.js-accounting-export-selected');
var exportAllBtn = document.querySelector('.js-accounting-export-all');
var selectionInfo = document.querySelector('.js-accounting-selection-info');
var tableRoot = document.querySelector('[data-table-list-id="accounting"]');
function getCheckedIds() {
if (!tableRoot) return [];
var items = tableRoot.querySelectorAll('.js-table-select-item:checked');
var ids = [];
for (var i = 0; i < items.length; i++) {
ids.push(items[i].value);
}
return ids;
}
function updateUI() {
var ids = getCheckedIds();
var count = ids.length;
if (exportSelectedBtn) {
exportSelectedBtn.disabled = count === 0;
}
if (selectionInfo) {
selectionInfo.textContent = count > 0 ? 'Zaznaczono: ' + count : '';
}
}
if (tableRoot) {
tableRoot.addEventListener('change', function(e) {
if (e.target && (e.target.classList.contains('js-table-select-item') || e.target.classList.contains('js-table-select-all'))) {
updateUI();
}
});
}
if (exportSelectedBtn) {
exportSelectedBtn.addEventListener('click', function() {
var ids = getCheckedIds();
if (ids.length === 0) return;
exportAllFlag.value = '0';
exportSelectedIds.innerHTML = '';
ids.forEach(function(id) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_ids[]';
input.value = id;
exportSelectedIds.appendChild(input);
});
form.submit();
});
}
if (exportAllBtn) {
exportAllBtn.addEventListener('click', function() {
exportAllFlag.value = '1';
exportSelectedIds.innerHTML = '';
form.submit();
});
}
updateUI();
})();
</script>

View File

@@ -47,6 +47,19 @@
</div>
</details>
<a class="sidebar__link<?= ($currentMenu ?? '') === 'accounting' ? ' is-active' : '' ?>" href="/accounting">
<span class="sidebar__icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</span>
<span class="sidebar__label"><?= $e($t('navigation.accounting_section')) ?></span>
</a>
<details class="sidebar__group<?= $currentMenu === 'settings' ? ' is-active' : '' ?>"<?= $currentMenu === 'settings' ? ' open' : '' ?>>
<summary class="sidebar__group-toggle">
<span class="sidebar__icon">

View File

@@ -36,6 +36,7 @@ use App\Modules\Settings\CompanySettingsController;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigController;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Accounting\AccountingController;
use App\Modules\Accounting\ReceiptController;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Settings\CronSettingsController;
@@ -192,6 +193,13 @@ return static function (Application $app): void {
$companySettingsRepository,
new OrdersRepository($app->db())
);
$accountingController = new AccountingController(
$template,
$translator,
$auth,
$receiptRepository,
$receiptConfigRepository
);
$allegroApiClient = new AllegroApiClient();
$shipmentPackageRepository = new ShipmentPackageRepository($app->db());
$shipmentService = new AllegroShipmentService(
@@ -299,6 +307,8 @@ return static function (Application $app): void {
$router->post('/settings/accounting/save', [$receiptConfigController, 'save'], [$authMiddleware]);
$router->post('/settings/accounting/toggle', [$receiptConfigController, 'toggleStatus'], [$authMiddleware]);
$router->post('/settings/accounting/delete', [$receiptConfigController, 'delete'], [$authMiddleware]);
$router->get('/accounting', [$accountingController, 'index'], [$authMiddleware]);
$router->post('/accounting/export', [$accountingController, 'export'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/create', [$receiptController, 'create'], [$authMiddleware]);
$router->post('/orders/{id}/receipt/store', [$receiptController, 'store'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/{receiptId}', [$receiptController, 'show'], [$authMiddleware]);

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\View\Template;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Modules\Auth\AuthService;
use App\Modules\Settings\ReceiptConfigRepository;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
final class AccountingController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs
) {
}
public function index(Request $request): Response
{
$filters = $this->parseFilters($request);
$result = $this->receipts->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$configs = array_filter(
$this->receiptConfigs->listAll(),
static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1
);
$configOptions = ['' => $this->translator->get('accounting.filters.any')];
foreach ($configs as $cfg) {
$configOptions[(string) ($cfg['id'] ?? '')] = (string) ($cfg['name'] ?? '');
}
$tableRows = array_map(fn(array $row): array => $this->toTableRow($row), (array) ($result['items'] ?? []));
$html = $this->template->render('accounting/index', [
'title' => $this->translator->get('accounting.title'),
'activeMenu' => 'accounting',
'user' => $this->auth->user(),
'tableList' => [
'list_key' => 'accounting',
'base_path' => '/accounting',
'query' => $filters,
'filters' => [
[
'key' => 'search',
'label' => $this->translator->get('accounting.filters.search'),
'type' => 'text',
'value' => $filters['search'],
],
[
'key' => 'config_id',
'label' => $this->translator->get('accounting.filters.config'),
'type' => 'select',
'value' => (string) $filters['config_id'],
'options' => $configOptions,
],
[
'key' => 'date_from',
'label' => $this->translator->get('accounting.filters.date_from'),
'type' => 'date',
'value' => $filters['date_from'],
],
[
'key' => 'date_to',
'label' => $this->translator->get('accounting.filters.date_to'),
'type' => 'date',
'value' => $filters['date_to'],
],
],
'columns' => [
['key' => 'receipt_number', 'label' => $this->translator->get('accounting.columns.number'), 'sortable' => true, 'sort_key' => 'receipt_number', 'raw' => true],
['key' => 'issue_date', 'label' => $this->translator->get('accounting.columns.issue_date'), 'sortable' => true, 'sort_key' => 'issue_date'],
['key' => 'sale_date', 'label' => $this->translator->get('accounting.columns.sale_date')],
['key' => 'total_gross', 'label' => $this->translator->get('accounting.columns.total_gross'), 'sortable' => true, 'sort_key' => 'total_gross'],
['key' => 'config_name', 'label' => $this->translator->get('accounting.columns.config')],
['key' => 'order_ref', 'label' => $this->translator->get('accounting.columns.order'), 'raw' => true],
],
'rows' => $tableRows,
'pagination' => [
'page' => (int) ($result['page'] ?? 1),
'total_pages' => $totalPages,
'total' => (int) ($result['total'] ?? 0),
'per_page' => (int) ($result['per_page'] ?? 20),
],
'per_page_options' => [20, 50, 100],
'empty_message' => $this->translator->get('accounting.empty'),
'show_actions' => false,
'selectable' => true,
'select_name' => 'selected_ids[]',
'select_value_key' => 'id',
],
'exportQuery' => $this->buildExportQuery($filters),
'csrfToken' => Csrf::token(),
], 'layouts/app');
return Response::html($html);
}
public function export(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::redirect('/accounting');
}
$exportAll = (string) $request->input('export_all', '0') === '1';
$selectedIds = (array) $request->input('selected_ids', []);
$selectedIds = array_filter(array_map('intval', $selectedIds), static fn(int $id): bool => $id > 0);
$filters = $this->parseFilters($request);
if ($exportAll) {
$rows = $this->receipts->exportData($filters);
} elseif ($selectedIds !== []) {
$rows = $this->receipts->findByIds($selectedIds);
} else {
return Response::redirect('/accounting');
}
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Paragony');
$headers = ['Numer', 'Data wystawienia', 'Data sprzedazy', 'Kwota brutto', 'Konfiguracja', 'Nr zamowienia', 'Nr referencyjny'];
foreach ($headers as $col => $header) {
$sheet->setCellValue([$col + 1, 1], $header);
}
$sheet->getStyle('1:1')->getFont()->setBold(true);
$rowNum = 2;
foreach ($rows as $row) {
$sheet->setCellValue([1, $rowNum], (string) ($row['receipt_number'] ?? ''));
$sheet->setCellValue([2, $rowNum], (string) ($row['issue_date'] ?? ''));
$sheet->setCellValue([3, $rowNum], (string) ($row['sale_date'] ?? ''));
$sheet->setCellValue([4, $rowNum], (float) ($row['total_gross'] ?? 0));
$sheet->setCellValue([5, $rowNum], (string) ($row['config_name'] ?? ''));
$sheet->setCellValue([6, $rowNum], (string) ($row['internal_order_number'] ?? $row['external_order_id'] ?? ''));
$sheet->setCellValue([7, $rowNum], (string) ($row['order_reference_value'] ?? ''));
$rowNum++;
}
foreach (range(1, 7) as $col) {
$sheet->getColumnDimensionByColumn($col)->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
$writer->save('php://output');
$content = ob_get_clean();
$filename = 'paragony_' . date('Y-m-d') . '.xlsx';
return new Response($content ?: '', 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
/**
* @return array<string, mixed>
*/
private function parseFilters(Request $request): array
{
return [
'search' => trim((string) $request->input('search', '')),
'config_id' => (int) $request->input('config_id', 0),
'date_from' => trim((string) $request->input('date_from', '')),
'date_to' => trim((string) $request->input('date_to', '')),
'sort' => (string) $request->input('sort', 'issue_date'),
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function toTableRow(array $row): array
{
$orderId = (int) ($row['order_id'] ?? 0);
$receiptId = (int) ($row['id'] ?? 0);
$receiptNumber = htmlspecialchars((string) ($row['receipt_number'] ?? ''), ENT_QUOTES, 'UTF-8');
$orderNumber = htmlspecialchars(
(string) ($row['internal_order_number'] ?? $row['external_order_id'] ?? '#' . $orderId),
ENT_QUOTES,
'UTF-8'
);
return [
'id' => (string) $receiptId,
'receipt_number' => '<a href="/orders/' . $orderId . '/receipt/' . $receiptId . '">' . $receiptNumber . '</a>',
'issue_date' => (string) ($row['issue_date'] ?? ''),
'sale_date' => (string) ($row['sale_date'] ?? ''),
'total_gross' => $row['total_gross'] !== null
? number_format((float) $row['total_gross'], 2, '.', ' ')
: '-',
'config_name' => (string) ($row['config_name'] ?? '-'),
'order_ref' => '<a href="/orders/' . $orderId . '">' . $orderNumber . '</a>',
];
}
/**
* @param array<string, mixed> $filters
*/
private function buildExportQuery(array $filters): string
{
$params = [];
if (($filters['search'] ?? '') !== '') {
$params['search'] = (string) $filters['search'];
}
if (($filters['config_id'] ?? 0) > 0) {
$params['config_id'] = (string) $filters['config_id'];
}
if (($filters['date_from'] ?? '') !== '') {
$params['date_from'] = (string) $filters['date_from'];
}
if (($filters['date_to'] ?? '') !== '') {
$params['date_to'] = (string) $filters['date_to'];
}
return $params !== [] ? '?' . http_build_query($params) : '';
}
}

View File

@@ -42,6 +42,32 @@ final class ReceiptRepository
return is_array($row) ? $row : null;
}
/**
* @param list<int> $ids
* @return list<array<string, mixed>>
*/
public function findByIds(array $ids): array
{
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$statement = $this->pdo->prepare(
"SELECT r.*, rc.name AS config_name,
o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
WHERE r.id IN ({$placeholders})
ORDER BY r.issue_date DESC"
);
$statement->execute(array_values($ids));
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<string, mixed> $data
*/
@@ -118,4 +144,132 @@ final class ReceiptRepository
return $number;
}
/**
* @param array<string, mixed> $filters
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function paginate(array $filters): array
{
$where = [];
$params = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(r.receipt_number LIKE :search OR o.internal_order_number LIKE :search2 OR o.external_order_id LIKE :search3)';
$params['search'] = '%' . $search . '%';
$params['search2'] = '%' . $search . '%';
$params['search3'] = '%' . $search . '%';
}
$configId = (int) ($filters['config_id'] ?? 0);
if ($configId > 0) {
$where[] = 'r.config_id = :config_id';
$params['config_id'] = $configId;
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '' && strtotime($dateFrom) !== false) {
$where[] = 'r.issue_date >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '' && strtotime($dateTo) !== false) {
$where[] = 'r.issue_date <= :date_to';
$params['date_to'] = $dateTo;
}
$whereClause = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
$allowedSort = ['receipt_number', 'issue_date', 'total_gross'];
$sort = in_array((string) ($filters['sort'] ?? ''), $allowedSort, true)
? (string) $filters['sort']
: 'issue_date';
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$countStmt = $this->pdo->prepare(
"SELECT COUNT(*) FROM receipts r
LEFT JOIN orders o ON o.id = r.order_id
{$whereClause}"
);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
$stmt = $this->pdo->prepare(
"SELECT r.*, rc.name AS config_name,
o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
{$whereClause}
ORDER BY r.{$sort} {$sortDir}
LIMIT {$perPage} OFFSET {$offset}"
);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return [
'items' => is_array($rows) ? $rows : [],
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @param array<string, mixed> $filters
* @return list<array<string, mixed>>
*/
public function exportData(array $filters): array
{
$where = [];
$params = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(r.receipt_number LIKE :search OR o.internal_order_number LIKE :search2 OR o.external_order_id LIKE :search3)';
$params['search'] = '%' . $search . '%';
$params['search2'] = '%' . $search . '%';
$params['search3'] = '%' . $search . '%';
}
$configId = (int) ($filters['config_id'] ?? 0);
if ($configId > 0) {
$where[] = 'r.config_id = :config_id';
$params['config_id'] = $configId;
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '' && strtotime($dateFrom) !== false) {
$where[] = 'r.issue_date >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '' && strtotime($dateTo) !== false) {
$where[] = 'r.issue_date <= :date_to';
$params['date_to'] = $dateTo;
}
$whereClause = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = $this->pdo->prepare(
"SELECT r.*, rc.name AS config_name,
o.internal_order_number, o.external_order_id
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
LEFT JOIN orders o ON o.id = r.order_id
{$whereClause}
ORDER BY r.issue_date DESC"
);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
}