feat(08-10-receipt-module): phases 08-10 complete — receipt issuing from orders

Phase 08 — DB Foundation:
- 3 new tables: receipt_configs, receipts, receipt_number_counters
- company_settings extended with BDO, REGON, KRS, logo fields

Phase 09 — Receipt Config:
- CRUD for receipt configurations (Settings > Accounting)
- ReceiptConfigController + ReceiptConfigRepository

Phase 10 — Receipt Issuing:
- ReceiptRepository with atomic numbering (INSERT ON DUPLICATE KEY UPDATE)
- ReceiptController with snapshot pattern (seller/buyer/items as JSON)
- "Wystaw paragon" button in order view
- Documents tab showing both receipts and marketplace documents
- Activity log entry on receipt creation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:49:06 +01:00
parent 3bccc7a533
commit ed057fc304
31 changed files with 2539 additions and 39 deletions

View File

@@ -27,10 +27,14 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
- [x] UX: orderpro-to-allegro disable, lista zamówień poprawki — Phase 7
- [x] Unit tests: AllegroTokenManager, AllegroOrderImportService (12 testów) — Phase 7
- [x] InPost ShipX API: natywny provider niezależny od Allegro — Phase 7
- [x] DB Foundation: tabele receipts, receipt_configs, receipt_number_counters + company_settings extended — Phase 8
- [x] Konfiguracja paragonów (CRUD w Ustawienia > Księgowość) — Phase 9
- [x] Wystawianie paragonów z zamówienia (formularz, snapshoty, atomowe numerowanie) — Phase 10
### Active (In Progress)
- [ ] [Awaiting next milestone definition]
- [ ] Podgląd i wydruk paragonu (HTML+PDF) — Phase 11
- [ ] Sekcja Księgowość — lista paragonów + eksport XLSX — Phase 12
### Planned (Next)
@@ -86,6 +90,9 @@ PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API p
| dg/bypass-finals do testów final classes | Wszystkie klasy final — mockowanie przez bypass-finals zamiast usuwania final | 2026-03-15 | Active |
| InPost ShipX API zamiast Allegro WZA remap | InpostIntegrationRepository jest pod ShipX; niezależność od Allegro | 2026-03-15 | Active |
| vendor/ w ftp-kr ignore | Auto-upload dev deps na serwer powodował Fatal Error | 2026-03-15 | Active |
| Snapshot pattern: seller/buyer/items jako JSON w receipts | Dane zamrożone w momencie wystawienia — niezależne od przyszłych zmian źródła | 2026-03-15 | Active |
| Atomowe numerowanie paragonów: INSERT ON DUPLICATE KEY UPDATE | Bezpieczne generowanie kolejnych numerów bez race conditions | 2026-03-15 | Active |
| Moduł Accounting w osobnym namespace | App\Modules\Accounting — separacja od Settings | 2026-03-15 | Active |
## Success Metrics
@@ -117,4 +124,4 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-03-15 after Phase 7 (Pre-Expansion Fixes — milestone v0.2 complete)*
*Last updated: 2026-03-15 after Phase 10 (Wystawianie paragonów z zamówienia)*

View File

@@ -6,7 +6,17 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
## Current Milestone
No active milestone. Run `/paul:milestone` to define next.
### v0.3 Moduł Paragonów — In progress
Moduł księgowości z obsługą paragonów: wielokonfiguracyjne szablony, wystawianie z zamówień, podgląd/wydruk HTML+PDF, lista z filtrami i eksportem XLSX.
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 8 | DB Foundation + Company Settings | 1/1 | Complete ✓ |
| 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 | Not started |
| 12 | Sekcja Księgowość — lista + eksport XLSX | 1 | Not started |
## Completed Milestones

View File

@@ -5,26 +5,31 @@
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.2 COMPLETE. Następny milestone do zdefiniowania.
**Current focus:** Milestone v0.3 Moduł Paragonów — Faza 11 Podgląd i wydruk paragonu.
## Current Position
Milestone: v0.2 Pre-Expansion Fixes — COMPLETE ✓
Phase: 7 of 7 (07-pre-expansion-fixes) — Complete
Plan: 07-01 ✓, 07-02 ✓, 07-03 ✓, 07-04 ✓, 07-05
Status: Milestone v0.2 complete — ready for next milestone
Last activity: 2026-03-15 — Phase 07 transition complete, milestone v0.2 done
Milestone: v0.3 Moduł Paragonów
Phase: 10 of 12 (10-receipt-issue) — Complete
Plan: 10-01
Status: Phase 10 complete — ready for Phase 11
Last activity: 2026-03-15 — Phase 10 loop closed
Progress:
- v0.1 Initial Release: [██████████] 100% ✓
- v0.2 Pre-Expansion Fixes: [██████████] 100% (5/5 planów)
- v0.2 Pre-Expansion Fixes: [██████████] 100%
- v0.3 Moduł Paragonów: [██████░░░░] 60%
- Phase 8: [██████████] 100% ✓
- Phase 9: [██████████] 100% ✓
- Phase 10: [██████████] 100% ✓
- Phase 11: [░░░░░░░░░░] 0%
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 07 complete — milestone v0.2 done]
✓ ✓ ✓ [Phase 10 complete — ready for next phase]
```
## Accumulated Context
@@ -42,6 +47,14 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 2026-03-15 | 3 bugi use-statement naprawione (odkryte przez testy) | Faza 07 | RuntimeException catch w 401 retry wreszcie działa; AllegroOAuthException rzucane poprawnie |
| 2026-03-15 | InPost ShipX API (nie Allegro WZA) jako natywny provider | Faza 07 | InpostShipmentService niezależny od Allegro; workaround remap usunięty |
| 2026-03-15 | vendor/ dodany do ftp-kr ignore; deploy vendor ręcznie | Faza 07 | Auto-upload nie nadpisze vendor/ na serwerze |
| 2026-03-15 | Snapshot pattern: seller/buyer/items jako JSON | Faza 10 | Dane zamrożone w momencie wystawienia paragonu |
| 2026-03-15 | Atomowe numerowanie: INSERT ON DUPLICATE KEY UPDATE | Faza 10 | Bezpieczne kolejne numery paragonów |
| 2026-03-15 | Moduł Accounting w App\Modules\Accounting | Faza 10 | Separacja od Settings |
### Skill Audit (Faza 10, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym UNIFY |
### Skill Audit (Faza 07, Plan 05)
| Oczekiwany | Wywołany | Uwagi |
@@ -112,7 +125,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: 5ab87a5 (feat(07-pre-expansion-fixes): complete phase 07 — milestone v0.2 done)
Last commit: pending (phase 10 commit)
Branch: main
Feature branches merged: none
@@ -122,13 +135,14 @@ Brak.
## Session Continuity
Last session: 2026-03-15
Stopped at: Milestone v0.2 complete
Next action: /paul:milestone (define next milestone)
Resume file: .paul/ROADMAP.md
Stopped at: Phase 10 complete
Next action: /paul:plan for Phase 11 (Podglad i wydruk paragonu HTML+PDF)
Resume file: .paul/phases/10-receipt-issue/10-01-SUMMARY.md
Resume context:
- v0.1: COMPLETE ✓ (6 phases, 15 plans — tech debt, bugs, quality)
- v0.2: COMPLETE ✓ (1 phase, 5 plans — performance, stability, UX, tests, InPost)
- Next milestone to define
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
- v0.3: IN PROGRESS — Phase 08-10 done, Phase 11 next
- Faza 0 (nieaktywne przyciski) zrobiona poza planem
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,203 @@
---
phase: 08-db-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260315_000050_create_receipt_configs_table.sql
- database/migrations/20260315_000051_create_receipts_table.sql
- database/migrations/20260315_000052_create_receipt_number_counters_table.sql
- database/migrations/20260315_000053_extend_company_settings_extra_fields.sql
- src/Modules/Settings/CompanySettingsRepository.php
- resources/views/settings/company.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
autonomous: false
---
<objective>
## Goal
Wdrożyć migracje bazodanowe dla modułu paragonów (receipt_configs, receipts, receipt_number_counters) oraz rozszerzyć company_settings o pola wymagane na paragonach (BDO, REGON, KRS, logo). Zaktualizować UI danych firmy o nowe pola.
## Purpose
Fundament bazodanowy pod cały moduł paragonów — bez tych tabel nie można przejść do logiki konfiguracji ani wystawiania.
## Output
- 4 pliki migracji SQL (już utworzone, do weryfikacji)
- Rozszerzony formularz danych firmy o pola BDO, REGON, KRS, logo
- Zaktualizowane DOCS/DB_SCHEMA.md i DOCS/ARCHITECTURE.md
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@database/migrations/20260315_000050_create_receipt_configs_table.sql
@database/migrations/20260315_000051_create_receipts_table.sql
@database/migrations/20260315_000052_create_receipt_number_counters_table.sql
@database/migrations/20260315_000053_extend_company_settings_extra_fields.sql
@src/Modules/Settings/CompanySettingsRepository.php
@resources/views/settings/company.php
@DOCS/DB_SCHEMA.md
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
No specialized flows configured for DB migrations.
</skills>
<acceptance_criteria>
## AC-1: Tabele paragonów istnieją po migracji
```gherkin
Given baza danych jest dostępna i migrator działa
When uruchomię migracje pending
Then istnieją tabele receipt_configs, receipts, receipt_number_counters z poprawnymi kolumnami, indeksami i FK
```
## AC-2: Company settings rozszerzony o nowe pola
```gherkin
Given tabela company_settings istnieje
When uruchomię migrację 000053
Then tabela ma kolumny bdo_number, regon, court_register, logo_path
```
## AC-3: Formularz danych firmy wyświetla i zapisuje nowe pola
```gherkin
Given użytkownik jest na stronie /settings/company
When wypełni pola BDO, REGON, KRS, logo i kliknie Zapisz
Then dane są zapisane w company_settings i widoczne po odświeżeniu
```
## AC-4: Dokumentacja zaktualizowana
```gherkin
Given migracje zostały wdrożone
When przeglądam DOCS/DB_SCHEMA.md i DOCS/ARCHITECTURE.md
Then widzę opisy nowych tabel receipt_configs, receipts, receipt_number_counters oraz nowych kolumn company_settings
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Weryfikacja i wdrożenie migracji SQL</name>
<files>
database/migrations/20260315_000050_create_receipt_configs_table.sql,
database/migrations/20260315_000051_create_receipts_table.sql,
database/migrations/20260315_000052_create_receipt_number_counters_table.sql,
database/migrations/20260315_000053_extend_company_settings_extra_fields.sql
</files>
<action>
1. Przejrzeć 4 pliki migracji już utworzonych — zweryfikować poprawność SQL:
- receipt_configs: pola name, is_active, number_format, numbering_type, is_named, sale_date_source, order_reference
- receipts: FK do orders i receipt_configs, UNIQUE na receipt_number, indeksy, JSON kolumny
- receipt_number_counters: UNIQUE na (config_id, year, month), FK do receipt_configs
- company_settings: ADD COLUMN IF NOT EXISTS dla bdo_number, regon, court_register, logo_path
2. Uruchomić migracje przez UI /settings/database lub bezpośrednio
3. Zweryfikować struktury tabel po migracji
</action>
<verify>Sprawdzić SHOW CREATE TABLE dla receipt_configs, receipts, receipt_number_counters; SHOW COLUMNS FROM company_settings</verify>
<done>AC-1 i AC-2 spełnione: tabele istnieją z poprawnymi kolumnami i kluczami</done>
</task>
<task type="auto">
<name>Task 2: Rozszerzenie formularza danych firmy o nowe pola</name>
<files>
src/Modules/Settings/CompanySettingsRepository.php,
resources/views/settings/company.php
</files>
<action>
1. W CompanySettingsRepository:
- Dodać bdo_number, regon, court_register, logo_path do metody defaults()
- Dodać te pola do saveSettings() (mapowanie z POST)
- Dodać te pola do getSettings() jeśli nie zwracane automatycznie
2. W resources/views/settings/company.php:
- Dodać 4 nowe pola formularza (input text) w sekcji danych firmy:
- Numer BDO (bdo_number) — varchar 20
- REGON (regon) — varchar 14
- KRS / Wpis do ewidencji (court_register) — varchar 128
- Logo firmy (logo_path) — na razie pole tekstowe ze ścieżką (upload w przyszłości)
- Zachować istniejący styl formularza
3. Nie zmieniać kontrolera — CompanySettingsController::save() przekazuje wszystkie pola POST do repository
</action>
<verify>Odwiedzić /settings/company — zobaczyć nowe pola, wypełnić, zapisać, odświeżyć — dane zachowane</verify>
<done>AC-3 spełnione: nowe pola widoczne i zapisywalne</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Migracje DB dla modułu paragonów + rozszerzony formularz danych firmy</what-built>
<how-to-verify>
1. Wejdź na /settings/database — uruchom pending migracje
2. Wejdź na /settings/company — sprawdź czy są nowe pola (BDO, REGON, KRS, Logo)
3. Wypełnij nowe pola, zapisz, odśwież — dane powinny być zachowane
4. Potwierdź że istniejące dane firmy nie zniknęły
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
<task type="auto">
<name>Task 3: Aktualizacja dokumentacji DB_SCHEMA.md i ARCHITECTURE.md</name>
<files>DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md</files>
<action>
1. W DOCS/DB_SCHEMA.md dodać sekcje:
- ### receipt_configs — z opisem kolumn, indeksów
- ### receipts — z opisem kolumn, FK, indeksów
- ### receipt_number_counters — z opisem kolumn, UNIQUE, FK
- Zaktualizować sekcję company_settings o nowe kolumny
- Dodać wpis chronologiczny w sekcji Status
2. W DOCS/ARCHITECTURE.md:
- Dodać moduł App\Modules\Accounting (przygotowanie)
- Dodać informację o nowych tabelach w sekcji domen
</action>
<verify>Przejrzeć oba pliki — sprawdzić kompletność opisów nowych tabel</verify>
<done>AC-4 spełnione: dokumentacja odzwierciedla nowy schemat</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Istniejące tabele (orders, order_items, order_activity_log) — żadnych zmian
- Istniejące kontrolery i widoki niezwiązane z company settings
- Logika istniejących integracji (Allegro, shopPRO, Apaczka, InPost)
## SCOPE LIMITS
- Nie tworzymy jeszcze kontrolera paragonów (Faza 09)
- Nie tworzymy widoków konfiguracji paragonów (Faza 09)
- Upload logo — na razie tylko pole tekstowe, upload w przyszłości
- Nie dodajemy nawigacji do sekcji Księgowość (Faza 12)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] 4 migracje wykonane bez błędów
- [ ] Tabele receipt_configs, receipts, receipt_number_counters istnieją z poprawnymi strukturami
- [ ] company_settings ma kolumny bdo_number, regon, court_register, logo_path
- [ ] Formularz /settings/company wyświetla i zapisuje nowe pola
- [ ] DOCS/DB_SCHEMA.md zaktualizowany
- [ ] DOCS/ARCHITECTURE.md zaktualizowany
- [ ] Istniejąca funkcjonalność danych firmy niezmieniona
</verification>
<success_criteria>
- Wszystkie 4 migracje wykonane pomyślnie
- Nowe pola w formularzu danych firmy działają (zapis + odczyt)
- Dokumentacja kompletna
- Brak regresji w istniejącej funkcjonalności
</success_criteria>
<output>
After completion, create `.paul/phases/08-db-foundation/08-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,131 @@
---
phase: 08-db-foundation
plan: 01
subsystem: database
tags: [mysql, migrations, receipts, company-settings]
requires: []
provides:
- receipt_configs table
- receipts table
- receipt_number_counters table
- company_settings extended fields (bdo, regon, krs, logo)
affects: [09-receipt-config, 10-receipt-issue, 11-receipt-print, 12-accounting-list]
tech-stack:
added: []
patterns: [snapshot-json-pattern for receipts]
key-files:
created:
- database/migrations/20260315_000050_create_receipt_configs_table.sql
- database/migrations/20260315_000051_create_receipts_table.sql
- database/migrations/20260315_000052_create_receipt_number_counters_table.sql
- database/migrations/20260315_000053_extend_company_settings_extra_fields.sql
modified:
- src/Modules/Settings/CompanySettingsRepository.php
- resources/views/settings/company.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
key-decisions:
- "receipts.order_id as BIGINT UNSIGNED (match orders.id type)"
- "receipts uses JSON snapshots for seller/buyer/items data"
- "receipt_configs ON DELETE RESTRICT (nie usuwaj konfiguracji z istniejacymi paragonami)"
patterns-established:
- "Snapshot pattern: dane sprzedawcy/nabywcy/pozycji zapisywane jako JSON w momencie wystawienia"
duration: ~15min
completed: 2026-03-15
---
# Phase 8 Plan 01: DB Foundation + Company Settings Summary
**Migracje bazodanowe dla modulu paragonow (3 nowe tabele) + rozszerzenie company_settings o BDO/REGON/KRS/logo.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Completed | 2026-03-15 |
| Tasks | 4 completed |
| Files modified | 8 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Tabele paragonow istnieja po migracji | Pass | receipt_configs, receipts, receipt_number_counters utworzone |
| AC-2: Company settings rozszerzony | Pass | bdo_number, regon, court_register, logo_path dodane |
| AC-3: Formularz wyswietla i zapisuje nowe pola | Pass | Zweryfikowane manualnie przez uzytkownika |
| AC-4: Dokumentacja zaktualizowana | Pass | DB_SCHEMA.md i ARCHITECTURE.md zaktualizowane |
## Accomplishments
- 3 nowe tabele bazodanowe dla modulu paragonow (receipt_configs, receipts, receipt_number_counters)
- Rozszerzenie company_settings o 4 pola wymagane na dokumentach ksiegowych
- Formularz danych firmy zaktualizowany o nowe pola z pelnym zapisem/odczytem
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260315_000050_*.sql` | Created | Tabela receipt_configs |
| `database/migrations/20260315_000051_*.sql` | Created | Tabela receipts |
| `database/migrations/20260315_000052_*.sql` | Created | Tabela receipt_number_counters |
| `database/migrations/20260315_000053_*.sql` | Created | Rozszerzenie company_settings |
| `src/Modules/Settings/CompanySettingsRepository.php` | Modified | Dodano 4 nowe pola (get/save/defaults) |
| `resources/views/settings/company.php` | Modified | Dodano pola REGON, BDO, KRS, Logo |
| `DOCS/DB_SCHEMA.md` | Modified | Dokumentacja 3 nowych tabel + nowe kolumny |
| `DOCS/ARCHITECTURE.md` | Modified | Dodano modul Accounting (w budowie) |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| receipts.order_id BIGINT UNSIGNED | orders.id jest BIGINT UNSIGNED — FK musi sie zgadzac | Fix bledu errno 150 |
| ON DELETE RESTRICT dla receipt_configs | Nie mozna usunac konfiguracji jesli istnieja paragony | Bezpieczenstwo danych |
| Logo jako sciezka tekstowa | Upload w przyszlosci, na razie pole tekstowe | Uproszczenie fazy 08 |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Krytyczny fix typu FK |
**Total impact:** Jeden fix krytyczny, brak scope creep.
### Auto-fixed Issues
**1. FK type mismatch receipts.order_id**
- **Found during:** Task 1 (migracje)
- **Issue:** receipts.order_id bylo INT UNSIGNED, orders.id jest BIGINT UNSIGNED
- **Fix:** Zmieniono na BIGINT UNSIGNED
- **Files:** database/migrations/20260315_000051_create_receipts_table.sql
- **Verification:** Migracja wykonana pomyslnie po poprawce
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| errno 150 FK constraint | Poprawiono typ order_id z INT na BIGINT UNSIGNED |
## Next Phase Readiness
**Ready:**
- Tabele bazodanowe gotowe pod CRUD konfiguracji (faza 09)
- Company settings kompletne pod snapshot danych sprzedawcy
**Concerns:**
- Brak
**Blockers:**
- Brak
---
*Phase: 08-db-foundation, Plan: 01*
*Completed: 2026-03-15*

View File

@@ -0,0 +1,308 @@
---
phase: 09-receipt-config
plan: 01
type: execute
wave: 1
depends_on: ["08-01"]
files_modified:
- src/Modules/Settings/ReceiptConfigController.php
- src/Modules/Settings/ReceiptConfigRepository.php
- resources/views/settings/accounting.php
- resources/views/layouts/app.php
- resources/lang/pl.php
- routes/web.php
- DOCS/ARCHITECTURE.md
autonomous: false
---
<objective>
## Goal
Wdrozyc CRUD konfiguracji paragonow w sekcji Ustawienia > Ksiegowosc: lista konfiguracji, tworzenie, edycja, aktywacja/dezaktywacja, usuwanie. Dodac sublinek "Ksiegowosc" do nawigacji ustawien.
## Purpose
Umozliwienie zarzadzania szablonami paragonow (numeracja, imiennosc, zrodlo daty sprzedazy) — fundament pod wystawianie paragonow w fazie 10.
## Output
- ReceiptConfigController (index, save, toggleStatus, delete)
- ReceiptConfigRepository (CRUD na receipt_configs)
- Widok settings/accounting.php z lista i formularzem
- Sublinek w sidebarze Ustawienia
- Tlumaczenia PL
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/08-db-foundation/08-01-SUMMARY.md
## Source Files (wzorce)
@src/Modules/Settings/CompanySettingsController.php
@src/Modules/Settings/CompanySettingsRepository.php
@resources/views/settings/company.php
@resources/views/layouts/app.php
@routes/web.php
@resources/lang/pl.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
</skills>
<acceptance_criteria>
## AC-1: Lista konfiguracji paragonow
```gherkin
Given uzytkownik jest zalogowany
When wchodzi na /settings/accounting
Then widzi liste wszystkich konfiguracji paragonow (nazwa, status, format numeracji, typ)
And widzi przycisk "Dodaj konfiguracje"
```
## AC-2: Tworzenie nowej konfiguracji
```gherkin
Given uzytkownik jest na /settings/accounting
When wypelnia formularz (nazwa, format numeracji, typ numeracji, imiennosc, zrodlo daty, referencja zamowienia) i klika Zapisz
Then konfiguracja jest zapisana w receipt_configs
And pojawia sie na liscie z flash message sukcesu
```
## AC-3: Edycja istniejącej konfiguracji
```gherkin
Given istnieje konfiguracja paragonow
When uzytkownik klika "Edytuj" przy konfiguracji
Then formularz jest wypelniony danymi konfiguracji
When zmienia dane i klika Zapisz
Then zmiany sa zapisane i widoczne na liscie
```
## AC-4: Aktywacja/dezaktywacja konfiguracji
```gherkin
Given istnieje konfiguracja paragonow
When uzytkownik klika przycisk aktywuj/dezaktywuj
Then status konfiguracji zmienia sie (is_active toggle)
And lista odswiezja sie z nowym statusem
```
## AC-5: Usuwanie konfiguracji
```gherkin
Given istnieje konfiguracja paragonow bez wystawionych paragonow
When uzytkownik klika "Usun" i potwierdza w OrderProAlerts.confirm()
Then konfiguracja jest usunieta z bazy
And znika z listy
```
## AC-6: Nawigacja — sublinek Ksiegowosc
```gherkin
Given uzytkownik jest zalogowany
When patrzy na sidebar w sekcji Ustawienia
Then widzi sublinek "Ksiegowosc" prowadzacy do /settings/accounting
And sublinek jest podswietlony gdy jest na stronie konfiguracji
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: ReceiptConfigRepository — CRUD na receipt_configs</name>
<files>src/Modules/Settings/ReceiptConfigRepository.php</files>
<action>
Utworzyc klase `App\Modules\Settings\ReceiptConfigRepository` wzorowana na CompanySettingsRepository:
- Konstruktor: `private readonly PDO $pdo`
- `listAll(): array` — SELECT * FROM receipt_configs ORDER BY created_at DESC
- `findById(int $id): ?array` — SELECT * WHERE id = :id
- `save(array $data): void` — INSERT lub UPDATE (jesli data['id'] istnieje):
- pola: name, is_active, number_format, numbering_type, is_named, sale_date_source, order_reference
- INSERT: prepared statement z VALUES
- UPDATE: prepared statement z WHERE id = :id
- `toggleStatus(int $id): void` — UPDATE receipt_configs SET is_active = NOT is_active WHERE id = :id
- `delete(int $id): bool` — DELETE FROM receipt_configs WHERE id = :id (zwraca false jesli FK RESTRICT zablokuje)
- Wszystko przez prepared statements (wzorzec z CompanySettingsRepository)
- Klasa final
</action>
<verify>Sprawdzic syntax PHP: php -l src/Modules/Settings/ReceiptConfigRepository.php</verify>
<done>AC-2, AC-3, AC-4, AC-5 (backend) spelnione</done>
</task>
<task type="auto">
<name>Task 2: ReceiptConfigController — kontroler CRUD</name>
<files>src/Modules/Settings/ReceiptConfigController.php</files>
<action>
Utworzyc klase `App\Modules\Settings\ReceiptConfigController` wzorowana na CompanySettingsController:
- Konstruktor: Template, Translator, AuthService, ReceiptConfigRepository (readonly)
- `index(Request $request): Response`:
- Pobiera liste konfiguracji z repository->listAll()
- Jesli query param `edit` — laduje konfiguracje do edycji (findById)
- Renderuje 'settings/accounting' z layout 'layouts/app'
- Przekazuje: activeMenu='settings', activeSettings='accounting', csrfToken, configs, editConfig, flash messages
- `save(Request $request): Response`:
- Waliduje CSRF (Csrf::validate)
- Waliduje: name wymagane, number_format wymagane i zawiera %N
- Wywoluje repository->save([...]) z danymi z $request->input(...)
- Flash::set('settings.accounting.flash.saved/save_failed')
- Redirect do /settings/accounting
- `toggleStatus(Request $request): Response`:
- Waliduje CSRF i id
- Wywoluje repository->toggleStatus((int) $request->input('id'))
- Redirect do /settings/accounting
- `delete(Request $request): Response`:
- Waliduje CSRF i id
- try: repository->delete((int) $request->input('id'))
- catch (Throwable): flash error (konfiguracja ma powiazane paragony)
- Redirect do /settings/accounting
- Klasa final
- Uzyc Flash::set/get wzorca, walidacja CSRF przez Csrf::validate($request->input('_token'))
</action>
<verify>Sprawdzic syntax PHP: php -l src/Modules/Settings/ReceiptConfigController.php</verify>
<done>AC-1, AC-2, AC-3, AC-4, AC-5 (kontroler) spelnione</done>
</task>
<task type="auto">
<name>Task 3: Widok settings/accounting.php, routing, nawigacja, tlumaczenia</name>
<files>
resources/views/settings/accounting.php,
resources/views/layouts/app.php,
routes/web.php,
resources/lang/pl.php
</files>
<action>
1. Utworzyc `resources/views/settings/accounting.php`:
- Sekcja .card z tytulem "Ksiegowosc — Konfiguracje paragonow"
- Flash messages (error/success)
- Tabela lista konfiguracji:
- Kolumny: Nazwa, Format numeracji, Typ, Imienny, Data sprzedazy, Ref. zamowienia, Status, Akcje
- Status: badge aktywny/nieaktywny
- Akcje: Edytuj (link z ?edit=ID), Aktywuj/Dezaktywuj (form POST), Usun (form POST z OrderProAlerts.confirm)
- Formularz dodawania/edycji (pod tabelą lub w osobnej sekcji):
- hidden: _token, id (jesli edycja)
- Nazwa (text, required)
- Format numeracji (text, required, placeholder: PAR/%N/%M/%Y)
- Typ numeracji (select: miesięczny/roczny)
- Imienny (checkbox)
- Domyslna data sprzedazy (select: wg daty zamowienia/oplacenia/wystawienia)
- Informacja o zamowieniu (select: brak/numer orderPRO/numer integracji)
- Przycisk Zapisz
- Styl: uzyc istniejacych klas .card, .form-control, .form-field, .btn, .form-grid-2, table.data-table
- Usun potwierdzenie: window.OrderProAlerts.confirm() (NIE natywny confirm())
2. W `resources/views/layouts/app.php`:
- Dodac sublinek "Ksiegowosc" po "Dane firmy" w sekcji settings:
```php
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'accounting' ? ' is-active' : '' ?>" href="/settings/accounting">
<?= $e($t('navigation.accounting')) ?>
</a>
```
3. W `routes/web.php`:
- Instantiacja: new ReceiptConfigRepository($app->db()), new ReceiptConfigController($template, $translator, $auth, $receiptConfigRepository)
- Trasy:
- GET /settings/accounting -> [$receiptConfigController, 'index'], [$authMiddleware]
- POST /settings/accounting/save -> [$receiptConfigController, 'save'], [$authMiddleware]
- POST /settings/accounting/toggle -> [$receiptConfigController, 'toggleStatus'], [$authMiddleware]
- POST /settings/accounting/delete -> [$receiptConfigController, 'delete'], [$authMiddleware]
- Dodac use statement dla nowych klas
4. W `resources/lang/pl.php`:
- navigation.accounting => 'Ksiegowosc'
- settings.accounting.title => 'Konfiguracje paragonow'
- settings.accounting.description => 'Zarzadzaj szablonami numeracji i ustawieniami paragonow'
- settings.accounting.fields.* => tlumaczenia pol formularza
- settings.accounting.flash.saved => 'Konfiguracja zapisana'
- settings.accounting.flash.save_failed => 'Blad zapisu konfiguracji'
- settings.accounting.flash.deleted => 'Konfiguracja usunieta'
- settings.accounting.flash.delete_failed => 'Nie mozna usunac — konfiguracja ma powiazane paragony'
- settings.accounting.flash.toggled => 'Status zmieniony'
- settings.accounting.actions.* => etykiety przyciskow
- settings.accounting.table.* => naglowki kolumn tabeli
- settings.accounting.options.* => opcje selectow (miesięczny/roczny, daty, referencje)
</action>
<verify>
Sprawdzic syntax: php -l resources/views/settings/accounting.php
Sprawdzic syntax routes: php -l routes/web.php
</verify>
<done>AC-1, AC-6 spelnione</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>CRUD konfiguracji paragonow w Ustawienia > Ksiegowosc</what-built>
<how-to-verify>
1. Wejdz na strone — w sidebarze Ustawienia powinien byc sublinek "Ksiegowosc"
2. Kliknij — powinna wyswietlic sie strona /settings/accounting z pusta lista i formularzem
3. Dodaj nowa konfiguracje: nazwa "Paragony standardowe", format "PAR/%N/%M/%Y", typ miesięczny
4. Sprawdz czy pojawila sie na liscie
5. Kliknij Edytuj — formularz powinien sie wypelnic danymi
6. Zmien nazwe i zapisz — sprawdz aktualizacje
7. Kliknij Dezaktywuj — status powinien sie zmienic
8. Kliknij Usun — powinien pojawic sie modal potwierdzenia (OrderProAlerts)
9. Potwierdz — konfiguracja powinna zniknac z listy
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
<task type="auto">
<name>Task 4: Aktualizacja ARCHITECTURE.md</name>
<files>DOCS/ARCHITECTURE.md</files>
<action>
Dodac w ARCHITECTURE.md:
1. W sekcji "Kluczowe klasy":
- App\Modules\Settings\ReceiptConfigController
- App\Modules\Settings\ReceiptConfigRepository
2. W sekcji "Routing" nowe trasy:
- GET /settings/accounting
- POST /settings/accounting/save
- POST /settings/accounting/toggle
- POST /settings/accounting/delete
3. Nowa sekcja "Przeplyw Ustawienia > Ksiegowosc" opisujaca CRUD konfiguracji
4. W sekcji "Nawigacja ustawien" dodac sublinek Ksiegowosc
</action>
<verify>Przejrzec ARCHITECTURE.md pod katem kompletnosci</verify>
<done>AC-1 do AC-6 udokumentowane</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- database/migrations/* — schemat zablokowany (migracje z fazy 08)
- Istniejace kontrolery i widoki niezwiazane z accounting
- src/Modules/Settings/CompanySettingsController.php
- src/Modules/Settings/CompanySettingsRepository.php
## SCOPE LIMITS
- Nie tworzymy sekcji glownej Ksiegowosc w sidebarze (to faza 12)
- Nie tworzymy logiki wystawiania paragonow (faza 10)
- Nie dodajemy walidacji unikalnosci nazwy konfiguracji (nice-to-have)
- Nie dodajemy sortowania/filtrowania listy konfiguracji (prostota)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] ReceiptConfigRepository — CRUD dziala (list, find, save, toggle, delete)
- [ ] ReceiptConfigController — 4 endpointy dzialaja
- [ ] Widok accounting.php renderuje liste i formularz
- [ ] Sublinek "Ksiegowosc" widoczny w sidebarze ustawien
- [ ] Tlumaczenia PL kompletne
- [ ] Routing zarejestrowany i dziala
- [ ] OrderProAlerts.confirm() dla usuwania
- [ ] ARCHITECTURE.md zaktualizowany
</verification>
<success_criteria>
- Uzytkownik moze tworzyc, edytowac, aktywowac/dezaktywowac i usuwac konfiguracje paragonow
- Nawigacja w sidebarze dziala poprawnie
- Brak regresji w istniejacych ustawieniach
</success_criteria>
<output>
After completion, create `.paul/phases/09-receipt-config/09-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,126 @@
---
phase: 09-receipt-config
plan: 01
subsystem: ui
tags: [php, settings, receipts, crud, scss]
requires:
- phase: 08-db-foundation
provides: receipt_configs table
provides:
- CRUD konfiguracji paragonow (Ustawienia > Ksiegowosc)
- ReceiptConfigController + ReceiptConfigRepository
affects: [10-receipt-issue, 11-receipt-print, 12-accounting-list]
tech-stack:
added: []
patterns: [settings-controller-pattern for new modules]
key-files:
created:
- src/Modules/Settings/ReceiptConfigController.php
- src/Modules/Settings/ReceiptConfigRepository.php
- resources/views/settings/accounting.php
modified:
- resources/views/layouts/app.php
- routes/web.php
- resources/lang/pl.php
- resources/scss/shared/_ui-components.scss
- resources/scss/app.scss
- public/assets/css/app.css
- DOCS/ARCHITECTURE.md
key-decisions:
- "Request::input() zamiast query() — klasa Request nie ma metody query()"
- "Csrf::token() i Csrf::validate() jako bool — nie rzuca wyjatkow"
- "Tabela .table zamiast .data-table — data-table nie istnieje w SCSS"
- "form-grid align-items: start — zapobiega rozciaganiu pol przez hint/small"
patterns-established:
- "Nowe strony ustawien: wzorzec ReceiptConfigController (index+save+toggle+delete)"
duration: ~30min
completed: 2026-03-15
---
# Phase 9 Plan 01: Konfiguracja paragonow (Ustawienia > Ksiegowosc) Summary
**CRUD konfiguracji paragonow z nawigacja, tlumaczeniami i poprawkami globalnymi CSS (form-control, form-grid align).**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~30min |
| Completed | 2026-03-15 |
| Tasks | 5 completed |
| Files modified | 10 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Lista konfiguracji | Pass | Tabela z kolumnami Nazwa/Format/Typ/Status/Akcje |
| AC-2: Tworzenie konfiguracji | Pass | Formularz z walidacja nazwy i formatu |
| AC-3: Edycja konfiguracji | Pass | Link ?edit=ID wypelnia formularz |
| AC-4: Toggle aktywnosci | Pass | POST /settings/accounting/toggle |
| AC-5: Usuwanie z potwierdzeniem | Pass | OrderProAlerts.confirm() |
| AC-6: Sublinek Ksiegowosc | Pass | Widoczny i aktywny w sidebarze |
## Accomplishments
- Pelny CRUD konfiguracji paragonow z walidacja i flash messages
- Sublinek "Ksiegowosc" w nawigacji ustawien
- Globalne poprawki CSS: form-control kompaktniejszy, form-grid align-items: start
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Settings/ReceiptConfigRepository.php` | Created | CRUD na receipt_configs |
| `src/Modules/Settings/ReceiptConfigController.php` | Created | 4 endpointy (index/save/toggle/delete) |
| `resources/views/settings/accounting.php` | Created | Lista + formularz konfiguracji |
| `resources/views/layouts/app.php` | Modified | Sublinek Ksiegowosc w sidebarze |
| `routes/web.php` | Modified | 4 trasy + use statements |
| `resources/lang/pl.php` | Modified | Tlumaczenia settings.accounting.* |
| `resources/scss/shared/_ui-components.scss` | Modified | form-control: min-height 30px, border-radius 6px |
| `resources/scss/app.scss` | Modified | form-grid-2/3/4: align-items start |
| `public/assets/css/app.css` | Modified | Build CSS |
| `DOCS/ARCHITECTURE.md` | Modified | Nowe trasy, klasy, przeplyw |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 4 | Krytyczne fixy API kontrolera + CSS |
### Auto-fixed Issues
**1. Request::query() nie istnieje**
- **Fix:** Zamieniono na Request::input() ktory obsluguje tez query params
**2. Csrf::generate() nie istnieje**
- **Fix:** Zamieniono na Csrf::token()
**3. Csrf::validate() zwraca bool, nie rzuca wyjatku**
- **Fix:** Zamieniono try/catch na if(!Csrf::validate())
**4. Klasa CSS data-table nie istnieje**
- **Fix:** Zamieniono na .table (istniejaca w _ui-components.scss)
## Next Phase Readiness
**Ready:**
- CRUD konfiguracji dziala — faza 10 moze pobierac aktywne konfiguracje do wystawiania paragonow
**Concerns:**
- Brak
**Blockers:**
- Brak
---
*Phase: 09-receipt-config, Plan: 01*
*Completed: 2026-03-15*

View File

@@ -0,0 +1,320 @@
---
phase: 10-receipt-issue
plan: 01
type: execute
wave: 1
depends_on: ["09-01"]
files_modified:
- src/Modules/Accounting/ReceiptRepository.php
- src/Modules/Accounting/ReceiptController.php
- src/Modules/Orders/OrdersController.php
- resources/views/orders/show.php
- resources/views/orders/receipt-create.php
- routes/web.php
- resources/lang/pl.php
- resources/scss/shared/_ui-components.scss
- public/assets/css/app.css
- DOCS/ARCHITECTURE.md
autonomous: false
---
<objective>
## Goal
Umozliwienie wystawiania paragonow z poziomu widoku zamowienia — przycisk "Wystaw paragon", formularz wyboru konfiguracji z podgladem pozycji, zapis do bazy z atomowym numerowaniem i snapshotem danych.
## Purpose
Kluczowa funkcjonalnosc modulu Ksiegowosci (v0.3) — sprzedawca moze wystawic paragon bezposrednio z zamowienia, bez opuszczania widoku zamowienia.
## Output
- `ReceiptRepository` — CRUD na `receipts` + atomowe numerowanie przez `receipt_number_counters`
- `ReceiptController` — formularz tworzenia paragonu + zapis
- Przycisk "Wystaw paragon" w widoku zamowienia
- Lista wystawionych paragonow w zakladce "Dokumenty"
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/08-db-foundation/08-01-SUMMARY.md — schemat tabel receipts, receipt_configs, receipt_number_counters
@.paul/phases/09-receipt-config/09-01-SUMMARY.md — CRUD konfiguracji, ReceiptConfigRepository, wzorce Request/Csrf
## Source Files
@database/migrations/20260315_000051_create_receipts_table.sql
@database/migrations/20260315_000052_create_receipt_number_counters_table.sql
@src/Modules/Settings/ReceiptConfigRepository.php
@src/Modules/Settings/CompanySettingsRepository.php
@src/Modules/Orders/OrdersController.php
@resources/views/orders/show.php
@routes/web.php
</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: Przycisk "Wystaw paragon" w widoku zamowienia
```gherkin
Given uzytkownik jest na stronie szczegulow zamowienia /orders/{id}
When sa aktywne konfiguracje paragonow
Then widoczny jest przycisk "Wystaw paragon" w sekcji akcji
And przycisk prowadzi do formularza /orders/{id}/receipt/create
```
## AC-2: Formularz wystawiania paragonu
```gherkin
Given uzytkownik otwiera /orders/{id}/receipt/create
When formularz sie laduje
Then widoczny jest select z aktywnymi konfiguracjami paragonow
And wyswietlona jest tabela pozycji zamowienia (nazwa, ilosc, cena, suma)
And wyswietlone sa pola: data wystawienia (domyslnie dzis), data sprzedazy (wg konfiguracji)
And widoczny jest podglad danych sprzedawcy z company_settings
And przycisk "Wystaw paragon" submituje formularz
```
## AC-3: Zapis paragonu z atomowym numerowaniem
```gherkin
Given uzytkownik wypelnia formularz i klika "Wystaw paragon"
When POST /orders/{id}/receipt/store jest wysylany
Then tworzony jest rekord w tabeli receipts z:
- receipt_number wygenerowanym atomowo z receipt_number_counters (INSERT ON DUPLICATE KEY UPDATE)
- seller_data_json jako snapshot company_settings
- items_json jako snapshot pozycji zamowienia
- total_net i total_gross obliczone z pozycji
- sale_date okreslona wg sale_date_source z konfiguracji
- order_reference_value wypelnione wg order_reference z konfiguracji
And uzytkownik jest przekierowany na /orders/{id} z flash success
```
## AC-4: Lista paragonow w zakladce Dokumenty
```gherkin
Given zamowienie ma wystawione paragony
When uzytkownik klika zakladke "Dokumenty"
Then wyswietlona jest tabela paragonow (numer, data wystawienia, kwota brutto, konfiguracja)
And kazdy paragon ma link do podgladu (na razie placeholder faza 11)
```
## AC-5: Walidacja — brak duplikatow i brak pustych konfiguracji
```gherkin
Given uzytkownik probuje wystawic paragon
When nie ma aktywnych konfiguracji
Then przycisk "Wystaw paragon" nie jest widoczny w widoku zamowienia
Given uzytkownik submituje formularz bez wybranej konfiguracji
When POST jest wysylany
Then zwracany jest blad walidacji i paragon nie jest tworzony
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: ReceiptRepository — CRUD + atomowe numerowanie</name>
<files>src/Modules/Accounting/ReceiptRepository.php</files>
<action>
Utworz nowy modul `src/Modules/Accounting/` z klasa `ReceiptRepository`:
1. **Konstruktor:** przyjmuje `PDO $pdo`
2. **getNextNumber(int $configId, string $numberFormat, string $numberingType): string**
- Atomowe numerowanie przez `INSERT INTO receipt_number_counters (config_id, year, month, last_number) VALUES (:config_id, :year, :month, 1) ON DUPLICATE KEY UPDATE last_number = last_number + 1`
- Odczyt `last_number` przez `SELECT last_number FROM receipt_number_counters WHERE config_id = :config_id AND year = :year AND month = :month`
- Dla `numbering_type = 'yearly'`: month = NULL (w unique key)
- Dla `numbering_type = 'monthly'`: month = biezacy miesiac
- Podmiana w formacie: `%N` → numer (z zerem wiodacym min 3 cyfry), `%M` → miesiac (2 cyfry), `%Y` → rok (4 cyfry)
3. **create(array $data): int**
- INSERT do `receipts` ze wszystkimi polami
- Zwraca `lastInsertId()`
4. **findByOrderId(int $orderId): array**
- SELECT receipts + LEFT JOIN receipt_configs (na nazwe konfiguracji)
- ORDER BY created_at DESC
5. **findById(int $id): ?array**
- SELECT * WHERE id = :id
Wzorzec: analogicznie do ReceiptConfigRepository (PDO, prepared statements, strict types).
Namespace: `App\Modules\Accounting`
</action>
<verify>Klasa parsuje sie bez bledow: `php -l src/Modules/Accounting/ReceiptRepository.php`</verify>
<done>AC-3 backend spelnione: atomowe numerowanie i zapis paragonu</done>
</task>
<task type="auto">
<name>Task 2: ReceiptController + routing + widok formularza</name>
<files>
src/Modules/Accounting/ReceiptController.php,
resources/views/orders/receipt-create.php,
routes/web.php,
resources/lang/pl.php
</files>
<action>
1. **ReceiptController** (`App\Modules\Accounting`):
- Konstruktor: `Template, Translator, AuthService, ReceiptRepository, ReceiptConfigRepository, CompanySettingsRepository, OrdersRepository`
- **create(Request $request): Response** — GET /orders/{id}/receipt/create
- Pobierz zamowienie przez OrdersRepository::findDetails($orderId)
- Pobierz aktywne konfiguracje: ReceiptConfigRepository::listAll() + filtruj is_active = 1
- Pobierz dane sprzedawcy: CompanySettingsRepository::getSettings()
- Renderuj widok `orders/receipt-create`
- **store(Request $request): Response** — POST /orders/{id}/receipt/store
- Walidacja: config_id wymagane, zamowienie istnieje
- CSRF: Csrf::validate()
- Pobierz konfiguracje (findById), zamowienie (findDetails), company settings
- Oblicz sale_date wg `sale_date_source`: order_date → ordered_at, payment_date → z payments, issue_date → dzis
- Oblicz order_reference_value wg `order_reference`: none → NULL, orderpro → internal_order_number, integration → external_order_id
- Zbuduj seller_data_json z company_settings (company_name, tax_number, street, city, postal_code, phone, email, bank_account, bdo_number, regon, court_register)
- Zbuduj buyer_data_json z address (invoice lub customer)
- Zbuduj items_json z pozycji zamowienia (original_name, quantity, original_price_with_tax, sku, ean)
- Oblicz total_gross = suma(qty * price), total_net = total_gross (paragony nie rozdzielaja netto/brutto — wartosc taka sama)
- Wygeneruj numer przez ReceiptRepository::getNextNumber()
- ReceiptRepository::create() ze wszystkimi danymi
- Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber)
- Redirect /orders/{id}
2. **Widok receipt-create.php:**
- Layout z naglowkiem "Wystaw paragon" + link powrotny do zamowienia
- Select z aktywnymi konfiguracjami (name + number_format)
- Tabela pozycji zamowienia (readonly): nazwa, ilosc, cena, suma
- Podsumowanie kwoty brutto
- Podglad danych sprzedawcy (readonly z company_settings)
- Pole daty wystawienia (input date, domyslnie dzis)
- Przycisk "Wystaw paragon" + CSRF token
3. **routes/web.php:**
- Dodaj use statements: `ReceiptController`, `ReceiptRepository` (z `App\Modules\Accounting`)
- Utworz instancje: `$receiptRepository = new ReceiptRepository($app->db())`
- Utworz kontroler: `$receiptController = new ReceiptController($template, $translator, $auth, $receiptRepository, $receiptConfigRepository, $companySettingsRepository, new OrdersRepository($app->db()))`
- Zarejestruj trasy:
- `GET /orders/{id}/receipt/create``[$receiptController, 'create']`
- `POST /orders/{id}/receipt/store``[$receiptController, 'store']`
4. **resources/lang/pl.php:**
- Dodaj klucze `receipts.create.title`, `receipts.create.select_config`, `receipts.create.issue_date`, `receipts.create.submit`, `receipts.create.seller_data`, `receipts.create.items`, `receipts.create.total`, `receipts.create.back`
Wzorzec kontrolera: analogicznie do ReceiptConfigController (Csrf::token(), Csrf::validate(), Flash::set/get, Request::input()).
NIE uzywaj natywnych alert()/confirm() — OrderProAlerts juz jest w uzyciu.
</action>
<verify>
- `php -l src/Modules/Accounting/ReceiptController.php`
- `php -l resources/views/orders/receipt-create.php`
- Otworz /orders/{id}/receipt/create w przegladarce — formularz sie wyswietla
</verify>
<done>AC-2, AC-3, AC-5 spelnione: formularz, zapis, walidacja</done>
</task>
<task type="auto">
<name>Task 3: Integracja z widokiem zamowienia — przycisk + zakladka Dokumenty</name>
<files>
src/Modules/Orders/OrdersController.php,
resources/views/orders/show.php,
routes/web.php
</files>
<action>
1. **OrdersController::show():**
- Dodaj zaleznosc: `ReceiptConfigRepository` i `ReceiptRepository` (przez konstruktor lub przekazanie w widoku)
- UWAGA: OrdersController ma juz 5 parametrow konstruktora. Zamiast rozszerzac konstruktor, przekaz dane przez nowe instancje w routes/web.php:
- W web.php: pobierz aktywne configs i receipts w zamknieciu routera GET /orders/{id} LUB
- Prostsze: dodaj `?ReceiptRepository` i `?ReceiptConfigRepository` do konstruktora OrdersController
- W metodzie show(): pobierz `$receiptConfigs = $this->receiptConfigs->listAll()` (filtruj aktywne)
- Pobierz `$receipts = $this->receipts->findByOrderId($orderId)`
- Przekaz do widoku: `'receiptConfigs' => $activeConfigs, 'receipts' => $receipts`
2. **orders/show.php — przycisk "Wystaw paragon":**
- W sekcji `.order-details-actions` (linia ~47-54):
- Dodaj przycisk miedzy "Przygotuj przesylke" a "Platnosc":
```php
<?php if (($receiptConfigs ?? []) !== []): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/receipt/create" class="btn btn--secondary">Wystaw paragon</a>
<?php endif; ?>
```
- Przycisk widoczny tylko gdy sa aktywne konfiguracje (AC-1, AC-5)
3. **orders/show.php — zakladka Dokumenty:**
- Zastap pusty placeholder w `data-order-tab-panel="documents"` (linia ~516-521):
- Tabela paragonow: Numer, Data wystawienia, Kwota brutto, Konfiguracja, Akcje
- Jesli brak paragonow: "Brak dokumentow"
- Akcja: link "Podglad" (na razie `#` — placeholder do fazy 11)
- Zaktualizuj licznik w zakladce: `Dokumenty (N)` gdzie N = count($receipts)
4. **routes/web.php:**
- Dodaj `use App\Modules\Accounting\ReceiptRepository;` na gorze
- Przekaz `$receiptRepository` i `$receiptConfigRepository` do konstruktora `$ordersController`
5. **DOCS/ARCHITECTURE.md:**
- Dodaj modul Accounting z klasami ReceiptRepository, ReceiptController
- Dodaj trasy GET/POST /orders/{id}/receipt/*
</action>
<verify>
- Otworz /orders/{id} — przycisk "Wystaw paragon" widoczny (jesli sa aktywne configs)
- Kliknij zakladke "Dokumenty" — tabela paragonow wyswietla sie
- Po wystawieniu paragonu — pojawia sie na liscie w zakladce Dokumenty
</verify>
<done>AC-1, AC-4 spelnione: przycisk w widoku zamowienia + lista paragonow w Dokumentach</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Wystawianie paragonow z poziomu zamowienia — pelny flow: przycisk → formularz → zapis → lista w Dokumentach</what-built>
<how-to-verify>
1. Otworz dowolne zamowienie /orders/{id}
2. Sprawdz: przycisk "Wystaw paragon" jest widoczny w akcjach
3. Kliknij "Wystaw paragon" — formularz sie otwiera
4. Wybierz konfiguracje, sprawdz podglad pozycji i danych sprzedawcy
5. Kliknij "Wystaw paragon" w formularzu
6. Sprawdz: redirect na zamowienie z flash "Paragon wystawiony: PAR/001/03/2026"
7. Kliknij zakladke "Dokumenty" — paragon widoczny na liscie
8. Wystaw drugi paragon — numer powinien byc PAR/002/03/2026
</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 — tabele juz istnieja z fazy 08)
- src/Modules/Settings/ReceiptConfigRepository.php (gotowe z fazy 09)
- src/Modules/Settings/ReceiptConfigController.php (gotowe z fazy 09)
- resources/views/settings/accounting.php (gotowe z fazy 09)
## SCOPE LIMITS
- Brak podgladu/wydruku paragonu — to faza 11
- Brak edycji/anulowania paragonu — poza zakresem v0.3
- Brak generowania PDF — to faza 11
- Paragony nie rozdzielaja netto/brutto (total_net = total_gross) — uproszczenie dla paragonow
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l src/Modules/Accounting/ReceiptRepository.php` — brak bledow
- [ ] `php -l src/Modules/Accounting/ReceiptController.php` — brak bledow
- [ ] Przycisk "Wystaw paragon" widoczny w /orders/{id} gdy sa aktywne konfiguracje
- [ ] Formularz /orders/{id}/receipt/create wyswietla pozycje zamowienia i konfiguracje
- [ ] Po wystawieniu paragonu: redirect + flash + rekord w bazie
- [ ] Numer paragonu generowany atomowo (kolejne numery nie powtarzaja sie)
- [ ] Zakladka Dokumenty wyswietla wystawione paragony
- [ ] Brak bledow w konsoli PHP
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Wszystkie 3 taski auto + 1 checkpoint ukonczone
- Wszystkie AC-1 do AC-5 spelnione
- Brak bledow PHP (php -l na wszystkich nowych plikach)
- Weryfikacja manualna przez uzytkownika (checkpoint)
</success_criteria>
<output>
After completion, create `.paul/phases/10-receipt-issue/10-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,142 @@
---
phase: 10-receipt-issue
plan: 01
subsystem: accounting
tags: [php, receipts, orders, crud, snapshots, atomic-numbering]
requires:
- phase: 08-db-foundation
provides: receipts, receipt_configs, receipt_number_counters tables
- phase: 09-receipt-config
provides: ReceiptConfigRepository CRUD, active configs
provides:
- Wystawianie paragonow z poziomu zamowienia
- ReceiptRepository (CRUD + atomowe numerowanie)
- ReceiptController (formularz + zapis ze snapshotami)
- Przycisk "Wystaw paragon" w widoku zamowienia
- Lista paragonow + dokumentow zewnetrznych w zakladce Dokumenty
- Activity log entry po wystawieniu paragonu
affects: [11-receipt-print, 12-accounting-list]
tech-stack:
added: []
patterns: [snapshot-json-pattern for seller/buyer/items, atomic-counter-pattern for receipt numbering]
key-files:
created:
- src/Modules/Accounting/ReceiptRepository.php
- src/Modules/Accounting/ReceiptController.php
- resources/views/orders/receipt-create.php
modified:
- src/Modules/Orders/OrdersController.php
- resources/views/orders/show.php
- routes/web.php
- resources/lang/pl.php
- DOCS/ARCHITECTURE.md
key-decisions:
- "ReceiptRepository w App\\Modules\\Accounting (nowy modul, nie w Settings)"
- "Snapshot pattern: seller/buyer/items jako JSON w momencie wystawienia"
- "Atomowe numerowanie: INSERT ON DUPLICATE KEY UPDATE na receipt_number_counters"
- "total_net = total_gross (paragony nie rozdzielaja netto/brutto)"
- "OrdersController rozszerzony o opcjonalne ?ReceiptRepository i ?ReceiptConfigRepository"
patterns-established:
- "Modul Accounting: osobny namespace dla funkcjonalnosci ksiegowych"
- "Snapshot przy tworzeniu dokumentu: dane zamrazane w JSON, niezalezne od przyszlych zmian zrodla"
- "Activity log: recordActivity() po kazdej akcji generujacej dokument"
duration: ~25min
completed: 2026-03-15
---
# Phase 10 Plan 01: Wystawianie paragonow z zamowienia Summary
**Pelny flow wystawiania paragonow: przycisk w zamowieniu, formularz z podgladem pozycji/sprzedawcy, zapis z atomowym numerowaniem i snapshotami, lista w zakladce Dokumenty + wpis w historii.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~25min |
| Completed | 2026-03-15 |
| Tasks | 3 auto + 1 checkpoint |
| Files created | 3 |
| Files modified | 5 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Przycisk "Wystaw paragon" w widoku zamowienia | Pass | Widoczny tylko gdy sa aktywne konfiguracje |
| AC-2: Formularz wystawiania paragonu | Pass | Select konfiguracji, tabela pozycji, podglad sprzedawcy, data wystawienia |
| AC-3: Zapis paragonu z atomowym numerowaniem | Pass | INSERT ON DUPLICATE KEY UPDATE, snapshoty JSON |
| AC-4: Lista paragonow w zakladce Dokumenty | Pass | Paragony + dokumenty zewnetrzne w osobnych sekcjach |
| AC-5: Walidacja — brak duplikatow i brak pustych konfiguracji | Pass | Przycisk ukryty bez konfiguracji, walidacja config_id |
## Accomplishments
- Nowy modul `App\Modules\Accounting` z ReceiptRepository i ReceiptController
- Atomowe numerowanie paragonow przez receipt_number_counters (INSERT ON DUPLICATE KEY UPDATE)
- Snapshoty seller/buyer/items jako JSON — dane zamrozone w momencie wystawienia
- Zakladka Dokumenty wyswietla zarowno paragony jak i dokumenty zewnetrzne z marketplace
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Accounting/ReceiptRepository.php` | Created | CRUD na receipts + atomowe numerowanie |
| `src/Modules/Accounting/ReceiptController.php` | Created | Formularz tworzenia + zapis paragonu + activity log |
| `resources/views/orders/receipt-create.php` | Created | Widok formularza wystawiania paragonu |
| `src/Modules/Orders/OrdersController.php` | Modified | Dodano ?ReceiptRepository, ?ReceiptConfigRepository do konstruktora + show() |
| `resources/views/orders/show.php` | Modified | Przycisk "Wystaw paragon", zakladka Dokumenty z paragony + dokumenty zewnetrzne |
| `routes/web.php` | Modified | Instancje ReceiptRepository/ReceiptController, 2 nowe trasy |
| `resources/lang/pl.php` | Modified | Tlumaczenia receipts.create.*, receipts.documents.*, receipt_issued |
| `DOCS/ARCHITECTURE.md` | Modified | Klasy Accounting, przeplyw wystawiania paragonu |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope additions | 2 | User feedback — activity log + dokumenty zewnetrzne |
**Total impact:** Dwa uzasadnione rozszerzenia poza planem, poprawiajace UX.
### Scope Additions
**1. Activity log po wystawieniu paragonu**
- **Source:** User feedback podczas checkpoint
- **Issue:** Brak wpisu w historii zmian zamowienia po wystawieniu paragonu
- **Fix:** Dodano recordActivity() z typem `receipt_issued` + tlumaczenie
- **Files:** ReceiptController.php, resources/lang/pl.php
**2. Dokumenty zewnetrzne w zakladce Dokumenty**
- **Source:** User feedback — licznik Dokumenty(2) ale widoczny tylko 1 paragon
- **Issue:** Zakladka wyswietlala tylko paragony, nie dokumenty z order_documents
- **Fix:** Dodano sekcje "Dokumenty zewnetrzne" z tabela order_documents
- **Files:** resources/views/orders/show.php
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Duplikacja instancji ReceiptConfigRepository/ReceiptRepository w web.php | Przeniesiono tworzenie przed ordersController, usunieto duplikaty |
## Next Phase Readiness
**Ready:**
- ReceiptRepository::findById() gotowe pod podglad paragonu (faza 11)
- Snapshoty JSON (seller, buyer, items) gotowe do renderowania HTML/PDF
- receipt_number unikalne — gotowe do wyswietlania
**Concerns:**
- Brak
**Blockers:**
- Brak
---
*Phase: 10-receipt-issue, Plan: 01*
*Completed: 2026-03-15*

View File

@@ -9,6 +9,7 @@
- `App\Modules\Orders`
- `App\Modules\Users`
- `App\Modules\Settings`
- `App\Modules\Accounting` (modul paragonow — wystawianie z zamowien)
## Routing
- `GET /login`, `POST /login`, `POST /logout`
@@ -54,6 +55,10 @@
- `POST /settings/integrations/shoppro/statuses/save`
- `POST /settings/integrations/shoppro/statuses/sync`
- `POST /settings/integrations/shoppro/delivery/save`
- `GET /settings/accounting`
- `POST /settings/accounting/save`
- `POST /settings/accounting/toggle`
- `POST /settings/accounting/delete`
- `GET /health`
- `GET /` (redirect)
@@ -111,6 +116,10 @@
- `App\Modules\Settings\AllegroOrdersSyncService`
- `App\Modules\Settings\AllegroOrderSyncStateRepository`
- `App\Modules\Settings\AllegroStatusSyncService`
- `App\Modules\Settings\ReceiptConfigController`
- `App\Modules\Settings\ReceiptConfigRepository`
- `App\Modules\Accounting\ReceiptRepository`
- `App\Modules\Accounting\ReceiptController`
- `App\Modules\Shipments\ShipmentProviderInterface`
- `App\Modules\Shipments\ShipmentProviderRegistry`
- `App\Modules\Shipments\ApaczkaShipmentService`
@@ -197,6 +206,39 @@
- `Statusy` (`/settings/statuses`).
- `Cron` (`/settings/cron`).
- `Integracje` (`/settings/integrations`) - wspolny hub konfiguracji providerow.
- `Ksiegowosc` (`/settings/accounting`) - konfiguracja paragonow.
## Przeplyw Ustawienia > Ksiegowosc (konfiguracja paragonow)
- `GET /settings/accounting`:
- `ReceiptConfigController::index(Request): Response`
- pobiera liste konfiguracji przez `ReceiptConfigRepository::listAll()`,
- opcjonalnie laduje konfiguracje do edycji przez `findById()` (query param `edit`),
- renderuje widok `resources/views/settings/accounting.php`.
- `POST /settings/accounting/save`:
- `ReceiptConfigController::save(Request): Response`
- waliduje CSRF, nazwe (wymagana) i format numeracji (wymagany, musi zawierac `%N`),
- zapisuje przez `ReceiptConfigRepository::save(...)` (INSERT lub UPDATE wg obecnosci `id`).
- `POST /settings/accounting/toggle`:
- `ReceiptConfigController::toggleStatus(Request): Response`
- przelacza `is_active` przez `ReceiptConfigRepository::toggleStatus(...)`.
- `POST /settings/accounting/delete`:
- `ReceiptConfigController::delete(Request): Response`
- usuwa konfiguracje przez `ReceiptConfigRepository::delete(...)`,
- FK RESTRICT blokuje usuniecie jesli istnieja powiazane paragony.
## Przeplyw Wystawianie paragonu z zamowienia
- `GET /orders/{id}/receipt/create`:
- `ReceiptController::create(Request): Response`
- pobiera zamowienie (OrdersRepository::findDetails), aktywne konfiguracje, dane sprzedawcy,
- renderuje formularz `resources/views/orders/receipt-create.php`.
- `POST /orders/{id}/receipt/store`:
- `ReceiptController::store(Request): Response`
- waliduje CSRF, config_id, istnienie zamowienia,
- buduje snapshoty: seller_data_json (z company_settings), buyer_data_json (z adresow zamowienia), items_json (z pozycji),
- oblicza total_gross, sale_date (wg sale_date_source z konfiguracji), order_reference_value,
- generuje numer atomowo przez `ReceiptRepository::getNextNumber(...)` (INSERT ON DUPLICATE KEY UPDATE na receipt_number_counters),
- zapisuje paragon przez `ReceiptRepository::create(...)`,
- redirect na /orders/{id} z flash success.
## Przeplyw Ustawienia > Cron
- `GET /settings/cron`:

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `receipt_configs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`number_format` VARCHAR(64) NOT NULL DEFAULT 'PAR/%N/%M/%Y',
`numbering_type` ENUM('monthly','yearly') NOT NULL DEFAULT 'monthly',
`is_named` TINYINT(1) NOT NULL DEFAULT 0,
`sale_date_source` ENUM('order_date','payment_date','issue_date') NOT NULL DEFAULT 'issue_date',
`order_reference` ENUM('none','orderpro','integration') NOT NULL DEFAULT 'none',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `receipts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` BIGINT UNSIGNED NOT NULL,
`config_id` INT UNSIGNED NOT NULL,
`receipt_number` VARCHAR(64) NOT NULL,
`issue_date` DATE NOT NULL,
`sale_date` DATE NOT NULL,
`seller_data_json` JSON NOT NULL,
`buyer_data_json` JSON DEFAULT NULL,
`items_json` JSON NOT NULL,
`total_net` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`total_gross` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`order_reference_value` VARCHAR(128) DEFAULT NULL,
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `receipts_number_unique` (`receipt_number`),
KEY `receipts_order_idx` (`order_id`),
KEY `receipts_config_idx` (`config_id`),
KEY `receipts_issue_date_idx` (`issue_date`),
CONSTRAINT `receipts_order_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `receipts_config_fk` FOREIGN KEY (`config_id`) REFERENCES `receipt_configs` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `receipt_number_counters` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`config_id` INT UNSIGNED NOT NULL,
`year` SMALLINT UNSIGNED NOT NULL,
`month` TINYINT UNSIGNED DEFAULT NULL,
`last_number` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `receipt_counters_config_period_unique` (`config_id`, `year`, `month`),
CONSTRAINT `receipt_counters_config_fk` FOREIGN KEY (`config_id`) REFERENCES `receipt_configs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,5 @@
ALTER TABLE `company_settings`
ADD COLUMN IF NOT EXISTS `bdo_number` VARCHAR(20) DEFAULT NULL AFTER `bank_owner_name`,
ADD COLUMN IF NOT EXISTS `regon` VARCHAR(14) DEFAULT NULL AFTER `bdo_number`,
ADD COLUMN IF NOT EXISTS `court_register` VARCHAR(128) DEFAULT NULL AFTER `regon`,
ADD COLUMN IF NOT EXISTS `logo_path` VARCHAR(255) DEFAULT NULL AFTER `court_register`;

View File

@@ -69,6 +69,12 @@
width: 100%;
}
.btn--disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.btn:active {
transform: translateY(1px);
}
@@ -81,10 +87,10 @@
.form-control {
width: 100%;
min-height: 34px;
min-height: 30px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 5px 10px;
border-radius: 6px;
padding: 4px 8px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;
@@ -674,7 +680,7 @@ h4.section-title::before {
.filters-actions {
display: flex;
align-items: end;
align-items: center;
gap: 8px;
}
@@ -692,18 +698,21 @@ h4.section-title::before {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.form-grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.form-grid-4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.form-actions {
@@ -1017,6 +1026,7 @@ h4.section-title::before {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
align-items: end;
}
.table-col-toggle-wrapper {

View File

@@ -33,6 +33,7 @@ return [
'apaczka' => 'Integracja Apaczka',
'inpost' => 'Integracja InPost',
'company' => 'Dane firmy',
'accounting' => 'Ksiegowosc',
],
'marketplace' => [
'title' => 'Marketplace',
@@ -188,6 +189,7 @@ return [
'shipment_created' => 'Przesylka WZA',
'shipment_label_downloaded' => 'Etykieta pobrana',
'shipment_error' => 'Blad przesylki',
'receipt_issued' => 'Paragon wystawiony',
],
'actors' => [
'system' => 'System',
@@ -1124,6 +1126,76 @@ return [
'save_failed' => 'Nie udalo sie zapisac danych firmy.',
],
],
'accounting' => [
'title' => 'Konfiguracje paragonow',
'description' => 'Zarzadzaj szablonami numeracji i ustawieniami paragonow.',
'table' => [
'heading' => 'Lista konfiguracji',
'empty' => 'Brak konfiguracji paragonow. Dodaj pierwsza ponizej.',
'name' => 'Nazwa',
'number_format' => 'Format numeracji',
'numbering_type' => 'Typ numeracji',
'is_named' => 'Imienny',
'sale_date_source' => 'Data sprzedazy',
'order_reference' => 'Ref. zamowienia',
'status' => 'Status',
'actions' => 'Akcje',
],
'form' => [
'add_heading' => 'Dodaj konfiguracje',
'edit_heading' => 'Edytuj konfiguracje',
],
'fields' => [
'name' => 'Nazwa konfiguracji',
'number_format' => 'Format numeracji',
'number_format_hint' => 'Zmienne: %N — numer, %M — miesiac, %Y — rok',
'numbering_type' => 'Typ numeracji',
'is_named' => 'Paragon imienny (dane klienta)',
'is_active' => 'Aktywna',
'sale_date_source' => 'Domyslna data sprzedazy',
'order_reference' => 'Informacja o zamowieniu',
],
'options' => [
'yes' => 'Tak',
'no' => 'Nie',
'active' => 'Aktywna',
'inactive' => 'Nieaktywna',
'numbering_type' => [
'monthly' => 'Miesieczny',
'yearly' => 'Roczny',
],
'sale_date_source' => [
'order_date' => 'Wg daty zamowienia',
'payment_date' => 'Wg daty oplacenia',
'issue_date' => 'Wg daty wystawienia',
],
'order_reference' => [
'none' => 'Brak',
'orderpro' => 'Numer orderPRO',
'integration' => 'Numer z integracji',
],
],
'actions' => [
'edit' => 'Edytuj',
'delete' => 'Usun',
'activate' => 'Aktywuj',
'deactivate' => 'Dezaktywuj',
'save_new' => 'Dodaj konfiguracje',
'save_edit' => 'Zapisz zmiany',
'cancel' => 'Anuluj',
],
'confirm' => [
'delete_title' => 'Usunac konfiguracje?',
'delete_message' => 'Czy na pewno chcesz usunac te konfiguracje paragonow?',
],
'flash' => [
'saved' => 'Konfiguracja zapisana.',
'save_failed' => 'Blad zapisu konfiguracji.',
'deleted' => 'Konfiguracja usunieta.',
'delete_failed' => 'Nie mozna usunac — konfiguracja ma powiazane paragony.',
'toggled' => 'Status konfiguracji zmieniony.',
],
],
'products' => [
'title' => 'Produkty',
'description' => 'Ustawienia generatora SKU dla produktow.',
@@ -1147,4 +1219,29 @@ return [
'title' => 'Przygotuj przesylke',
],
],
'receipts' => [
'create' => [
'title' => 'Wystaw paragon',
'back' => 'Powrot do zamowienia',
'select_config' => 'Konfiguracja paragonu',
'issue_date' => 'Data wystawienia',
'items' => 'Pozycje zamowienia',
'total' => 'Razem brutto',
'seller_data' => 'Dane sprzedawcy',
'submit' => 'Wystaw paragon',
'cancel' => 'Anuluj',
'no_configs' => 'Brak aktywnych konfiguracji paragonow',
'no_config_selected' => 'Wybierz konfiguracje paragonu',
'invalid_config' => 'Nieprawidlowa konfiguracja paragonu',
],
'documents' => [
'number' => 'Numer',
'issue_date' => 'Data wystawienia',
'total_gross' => 'Kwota brutto',
'config' => 'Konfiguracja',
'actions' => 'Akcje',
'preview' => 'Podglad',
'empty' => 'Brak dokumentow',
],
],
];

View File

@@ -441,7 +441,7 @@ h4.section-title {
.filters-actions {
display: flex;
align-items: end;
align-items: center;
gap: 8px;
}
@@ -459,18 +459,21 @@ h4.section-title {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.form-grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.form-grid-4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.form-actions {
@@ -784,6 +787,7 @@ h4.section-title {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
align-items: end;
}
.table-col-toggle-wrapper {

View File

@@ -68,6 +68,12 @@
width: 100%;
}
.btn--disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.btn:active {
transform: translateY(1px);
}
@@ -80,10 +86,10 @@
.form-control {
width: 100%;
min-height: 34px;
min-height: 30px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 5px 10px;
border-radius: 6px;
padding: 4px 8px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;

View File

@@ -156,10 +156,13 @@ $buildUrl = static function (array $params = []) use ($basePath, $query): string
<?php endif; ?>
<?php endforeach; ?>
<div class="filters-actions">
<button type="submit" class="btn btn--primary">Szukaj</button>
<a href="<?= $e($basePath) ?>" class="btn btn--secondary js-table-filters-clear">Wyczysc</a>
</div>
<label class="form-field">
<span class="field-label">&nbsp;</span>
<div class="filters-actions">
<button type="submit" class="btn btn--primary">Szukaj</button>
<a href="<?= $e($basePath) ?>" class="btn btn--secondary js-table-filters-clear">Wyczysc</a>
</div>
</label>
</form>
</div>
<?php endif; ?>

View File

@@ -79,6 +79,9 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'company' ? ' is-active' : '' ?>" href="/settings/company">
<?= $e($t('navigation.company')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'accounting' ? ' is-active' : '' ?>" href="/settings/accounting">
<?= $e($t('navigation.accounting')) ?>
</a>
</div>
</details>
</nav>

View File

@@ -0,0 +1,104 @@
<?php
$orderRow = is_array($order ?? null) ? $order : [];
$itemsList = is_array($items ?? null) ? $items : [];
$configsList = is_array($configs ?? null) ? $configs : [];
$sellerData = is_array($seller ?? null) ? $seller : [];
$totalGrossVal = (float) ($totalGross ?? 0);
$orderIdVal = (int) ($orderId ?? 0);
?>
<section class="card">
<div class="order-details-head">
<div>
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="order-back-link">&larr; <?= $e($t('receipts.create.back')) ?></a>
<h2 class="section-title mt-12"><?= $e($t('receipts.create.title')) ?></h2>
<div class="order-details-sub mt-4">
<?= $e($t('orders.details.title')) ?> <?= $e((string) ($orderRow['internal_order_number'] ?? ('#' . $orderIdVal))) ?>
</div>
</div>
</div>
<form method="post" action="/orders/<?= $e((string) $orderIdVal) ?>/receipt/store" class="mt-16">
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
<div class="form-grid-2">
<div class="form-group">
<label class="form-label" for="config_id"><?= $e($t('receipts.create.select_config')) ?></label>
<select name="config_id" id="config_id" class="form-control" required>
<option value="">— <?= $e($t('receipts.create.select_config')) ?> —</option>
<?php foreach ($configsList as $cfg): ?>
<option value="<?= $e((string) ($cfg['id'] ?? '')) ?>">
<?= $e((string) ($cfg['name'] ?? '')) ?> (<?= $e((string) ($cfg['number_format'] ?? '')) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="issue_date"><?= $e($t('receipts.create.issue_date')) ?></label>
<input type="date" name="issue_date" id="issue_date" class="form-control" value="<?= $e(date('Y-m-d')) ?>" required>
</div>
</div>
<h3 class="section-title mt-16"><?= $e($t('receipts.create.items')) ?></h3>
<div class="table-wrap mt-8">
<table class="table table--details">
<thead>
<tr>
<th>Lp.</th>
<th><?= $e($t('orders.details.item_name')) ?></th>
<th>SKU/EAN</th>
<th><?= $e($t('orders.details.item_qty')) ?></th>
<th><?= $e($t('orders.details.item_price')) ?></th>
<th><?= $e($t('orders.details.item_sum')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($itemsList === []): ?>
<tr><td colspan="6" class="muted">Brak pozycji</td></tr>
<?php endif; ?>
<?php foreach ($itemsList as $idx => $item): ?>
<?php
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : null;
$sum = $price !== null ? ($qty * $price) : null;
?>
<tr>
<td><?= $e((string) ($idx + 1)) ?></td>
<td><?= $e((string) ($item['original_name'] ?? '')) ?></td>
<td>
<div><?= $e((string) ($item['sku'] ?? '-')) ?></div>
<div class="muted"><?= $e((string) ($item['ean'] ?? '-')) ?></div>
</td>
<td><?= $e((string) $qty) ?></td>
<td><?= $e($price !== null ? number_format($price, 2, '.', ' ') : '-') ?></td>
<td><?= $e($sum !== null ? number_format($sum, 2, '.', ' ') : '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="5" class="text-right"><strong><?= $e($t('receipts.create.total')) ?></strong></td>
<td><strong><?= $e(number_format($totalGrossVal, 2, '.', ' ')) ?> <?= $e((string) ($orderRow['currency'] ?? 'PLN')) ?></strong></td>
</tr>
</tfoot>
</table>
</div>
<h3 class="section-title mt-16"><?= $e($t('receipts.create.seller_data')) ?></h3>
<div class="receipt-seller-preview mt-8">
<dl class="order-kv">
<dt>Firma</dt><dd><?= $e((string) ($sellerData['company_name'] ?? '-')) ?></dd>
<dt>NIP</dt><dd><?= $e((string) ($sellerData['tax_number'] ?? '-')) ?></dd>
<dt>Adres</dt><dd><?= $e((string) ($sellerData['street'] ?? '')) ?>, <?= $e((string) ($sellerData['postal_code'] ?? '')) ?> <?= $e((string) ($sellerData['city'] ?? '')) ?></dd>
<dt>Telefon</dt><dd><?= $e((string) ($sellerData['phone'] ?? '-')) ?></dd>
<dt>Email</dt><dd><?= $e((string) ($sellerData['email'] ?? '-')) ?></dd>
</dl>
</div>
<div class="mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('receipts.create.submit')) ?></button>
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="btn btn--secondary ml-8"><?= $e($t('receipts.create.cancel')) ?></a>
</div>
</form>
</section>

View File

@@ -7,6 +7,8 @@ $shipmentsList = is_array($shipments ?? null) ? $shipments : [];
$packagesList = is_array($packages ?? null) ? $packages : [];
$documentsList = is_array($documents ?? null) ? $documents : [];
$notesList = is_array($notes ?? null) ? $notes : [];
$receiptsList = is_array($receipts ?? null) ? $receipts : [];
$receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : [];
$historyList = is_array($history ?? null) ? $history : [];
$activityLogList = is_array($activityLog ?? null) ? $activityLog : [];
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
@@ -45,12 +47,15 @@ foreach ($addressesList as $address) {
</div>
</div>
<div class="order-details-actions">
<button type="button" class="btn btn--secondary">Strefa klienta</button>
<button type="button" class="btn btn--secondary btn--disabled">Strefa klienta</button>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/prepare" class="btn btn--secondary">Przygotuj przesylke</a>
<button type="button" class="btn btn--secondary">Platnosc</button>
<button type="button" class="btn btn--secondary">Drukuj</button>
<button type="button" class="btn btn--primary">Pakuj</button>
<button type="button" class="btn btn--secondary">Edytuj</button>
<?php if ($receiptConfigsList !== []): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/receipt/create" class="btn btn--secondary">Wystaw paragon</a>
<?php endif; ?>
<button type="button" class="btn btn--secondary btn--disabled">Platnosc</button>
<button type="button" class="btn btn--secondary btn--disabled">Drukuj</button>
<button type="button" class="btn btn--primary btn--disabled">Pakuj</button>
<button type="button" class="btn btn--secondary btn--disabled">Edytuj</button>
</div>
</div>
<?php if ($flashSuccessMsg !== ''): ?>
@@ -100,7 +105,7 @@ foreach ($addressesList as $address) {
<button type="button" class="order-details-tab" data-order-tab-target="history"><?= $e($t('orders.details.tabs.history')) ?> (<?= $e((string) count($activityLogList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="shipments"><?= $e($t('orders.details.tabs.shipments')) ?> (<?= $e((string) (count($shipmentsList) + count($packagesList))) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="payments"><?= $e($t('orders.details.tabs.payments')) ?> (<?= $e((string) count($paymentsList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="documents"><?= $e($t('orders.details.tabs.documents')) ?> (<?= $e((string) count($documentsList)) ?>)</button>
<button type="button" class="order-details-tab" data-order-tab-target="documents"><?= $e($t('orders.details.tabs.documents')) ?> (<?= $e((string) (count($documentsList) + count($receiptsList))) ?>)</button>
</section>
<div class="order-tab-panel is-active" data-order-tab-panel="details">
@@ -516,7 +521,63 @@ foreach ($addressesList as $address) {
<div class="order-tab-panel" data-order-tab-panel="documents">
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('orders.details.tabs.documents')) ?></h3>
<div class="order-empty-placeholder mt-12"></div>
<?php if ($receiptsList === [] && $documentsList === []): ?>
<p class="muted mt-12"><?= $e($t('receipts.documents.empty')) ?></p>
<?php endif; ?>
<?php if ($receiptsList !== []): ?>
<h4 class="section-title mt-12">Paragony</h4>
<div class="table-wrap mt-8">
<table class="table table--details">
<thead>
<tr>
<th><?= $e($t('receipts.documents.number')) ?></th>
<th><?= $e($t('receipts.documents.issue_date')) ?></th>
<th><?= $e($t('receipts.documents.total_gross')) ?></th>
<th><?= $e($t('receipts.documents.config')) ?></th>
<th><?= $e($t('receipts.documents.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($receiptsList as $receipt): ?>
<tr>
<td><strong><?= $e((string) ($receipt['receipt_number'] ?? '')) ?></strong></td>
<td class="text-nowrap"><?= $e((string) ($receipt['issue_date'] ?? '')) ?></td>
<td class="text-nowrap"><?= $e($receipt['total_gross'] !== null ? number_format((float) $receipt['total_gross'], 2, '.', ' ') : '-') ?></td>
<td><?= $e((string) ($receipt['config_name'] ?? '-')) ?></td>
<td>
<span class="btn btn--sm btn--secondary btn--disabled"><?= $e($t('receipts.documents.preview')) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($documentsList !== []): ?>
<h4 class="section-title mt-12">Dokumenty zewnetrzne</h4>
<div class="table-wrap mt-8">
<table class="table table--details">
<thead>
<tr>
<th>Numer</th>
<th>Typ</th>
<th>Kwota brutto</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<?php foreach ($documentsList as $doc): ?>
<tr>
<td><strong><?= $e((string) ($doc['document_number'] ?? '-')) ?></strong></td>
<td><?= $e((string) ($doc['document_type_id'] ?? '-')) ?></td>
<td class="text-nowrap"><?= $e($doc['price_with_tax'] !== null ? number_format((float) $doc['price_with_tax'], 2, '.', ' ') . ' ' . ($doc['currency'] ?? '') : '-') ?></td>
<td class="text-nowrap"><?= $e((string) ($doc['source_created_at'] ?? '-')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
</div>
</div>

View File

@@ -0,0 +1,158 @@
<?php
$configs = is_array($configs ?? null) ? $configs : [];
$ec = is_array($editConfig ?? null) ? $editConfig : null;
$isEdit = $ec !== null;
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.accounting.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.accounting.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.accounting.table.heading')) ?></h3>
<?php if (count($configs) === 0): ?>
<p class="muted mt-12"><?= $e($t('settings.accounting.table.empty')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.accounting.table.name')) ?></th>
<th><?= $e($t('settings.accounting.table.number_format')) ?></th>
<th><?= $e($t('settings.accounting.table.numbering_type')) ?></th>
<th><?= $e($t('settings.accounting.table.status')) ?></th>
<th><?= $e($t('settings.accounting.table.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($configs as $cfg): ?>
<tr>
<td><?= $e((string) ($cfg['name'] ?? '')) ?></td>
<td><code><?= $e((string) ($cfg['number_format'] ?? '')) ?></code></td>
<td><?= $e($t('settings.accounting.options.numbering_type.' . ($cfg['numbering_type'] ?? 'monthly'))) ?></td>
<td>
<?php if (((int) ($cfg['is_active'] ?? 0)) === 1): ?>
<span class="badge badge--success"><?= $e($t('settings.accounting.options.active')) ?></span>
<?php else: ?>
<span class="badge badge--muted"><?= $e($t('settings.accounting.options.inactive')) ?></span>
<?php endif; ?>
</td>
<td style="white-space:nowrap">
<a href="/settings/accounting?edit=<?= (int) ($cfg['id'] ?? 0) ?>" class="btn btn--sm btn--secondary"><?= $e($t('settings.accounting.actions.edit')) ?></a>
<form action="/settings/accounting/toggle" method="post" style="display:inline">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($cfg['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">
<?= ((int) ($cfg['is_active'] ?? 0)) === 1 ? $e($t('settings.accounting.actions.deactivate')) : $e($t('settings.accounting.actions.activate')) ?>
</button>
</form>
<form action="/settings/accounting/delete" method="post" style="display:inline" class="js-confirm-delete">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($cfg['id'] ?? 0) ?>">
<button type="button" class="btn btn--sm btn--danger js-delete-btn"><?= $e($t('settings.accounting.actions.delete')) ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $isEdit ? $e($t('settings.accounting.form.edit_heading')) : $e($t('settings.accounting.form.add_heading')) ?></h3>
<form action="/settings/accounting/save" method="post" novalidate class="mt-12">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int) ($ec['id'] ?? 0) ?>">
<?php endif; ?>
<div class="form-grid-2">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.accounting.fields.name')) ?> *</span>
<input class="form-control" type="text" name="name" maxlength="128" required value="<?= $e((string) ($ec['name'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.accounting.fields.number_format')) ?> *</span>
<input class="form-control" type="text" name="number_format" maxlength="64" required placeholder="PAR/%N/%M/%Y" value="<?= $e((string) ($ec['number_format'] ?? 'PAR/%N/%M/%Y')) ?>">
<small class="field-hint"><?= $e($t('settings.accounting.fields.number_format_hint')) ?></small>
</label>
</div>
<div class="form-grid-3 mt-0">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.accounting.fields.numbering_type')) ?></span>
<select class="form-control" name="numbering_type">
<option value="monthly"<?= ((string) ($ec['numbering_type'] ?? 'monthly')) === 'monthly' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.numbering_type.monthly')) ?></option>
<option value="yearly"<?= ((string) ($ec['numbering_type'] ?? '')) === 'yearly' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.numbering_type.yearly')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.accounting.fields.sale_date_source')) ?></span>
<select class="form-control" name="sale_date_source">
<option value="issue_date"<?= ((string) ($ec['sale_date_source'] ?? 'issue_date')) === 'issue_date' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.sale_date_source.issue_date')) ?></option>
<option value="order_date"<?= ((string) ($ec['sale_date_source'] ?? '')) === 'order_date' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.sale_date_source.order_date')) ?></option>
<option value="payment_date"<?= ((string) ($ec['sale_date_source'] ?? '')) === 'payment_date' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.sale_date_source.payment_date')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.accounting.fields.order_reference')) ?></span>
<select class="form-control" name="order_reference">
<option value="none"<?= ((string) ($ec['order_reference'] ?? 'none')) === 'none' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.order_reference.none')) ?></option>
<option value="orderpro"<?= ((string) ($ec['order_reference'] ?? '')) === 'orderpro' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.order_reference.orderpro')) ?></option>
<option value="integration"<?= ((string) ($ec['order_reference'] ?? '')) === 'integration' ? ' selected' : '' ?>><?= $e($t('settings.accounting.options.order_reference.integration')) ?></option>
</select>
</label>
</div>
<div class="form-grid-2 mt-0">
<label class="form-field" style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_named" value="1"<?= ((int) ($ec['is_named'] ?? 0)) === 1 ? ' checked' : '' ?>>
<span class="field-label" style="margin:0"><?= $e($t('settings.accounting.fields.is_named')) ?></span>
</label>
<label class="form-field" style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_active" value="1"<?= $isEdit ? (((int) ($ec['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
<span class="field-label" style="margin:0"><?= $e($t('settings.accounting.fields.is_active')) ?></span>
</label>
</div>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $isEdit ? $e($t('settings.accounting.actions.save_edit')) : $e($t('settings.accounting.actions.save_new')) ?></button>
<?php if ($isEdit): ?>
<a href="/settings/accounting" class="btn btn--secondary"><?= $e($t('settings.accounting.actions.cancel')) ?></a>
<?php endif; ?>
</div>
</form>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.js-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var form = this.closest('form');
if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
window.OrderProAlerts.confirm(
'<?= $e($t('settings.accounting.confirm.delete_title')) ?>',
'<?= $e($t('settings.accounting.confirm.delete_message')) ?>',
function() { form.submit(); }
);
} else {
if (confirm('<?= $e($t('settings.accounting.confirm.delete_message')) ?>')) {
form.submit();
}
}
});
});
});
</script>

View File

@@ -72,6 +72,26 @@ $s = is_array($settings ?? null) ? $settings : [];
<input class="form-control" type="text" name="tax_number" maxlength="64" value="<?= $e((string) ($s['tax_number'] ?? '')) ?>">
</label>
<div class="form-grid-3 mt-0">
<label class="form-field">
<span class="field-label">REGON</span>
<input class="form-control" type="text" name="regon" maxlength="14" value="<?= $e((string) ($s['regon'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">Numer BDO</span>
<input class="form-control" type="text" name="bdo_number" maxlength="20" value="<?= $e((string) ($s['bdo_number'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">KRS / Wpis do ewidencji</span>
<input class="form-control" type="text" name="court_register" maxlength="128" value="<?= $e((string) ($s['court_register'] ?? '')) ?>">
</label>
</div>
<label class="form-field">
<span class="field-label">Logo firmy (sciezka)</span>
<input class="form-control" type="text" name="logo_path" maxlength="255" placeholder="np. uploads/logo.png" value="<?= $e((string) ($s['logo_path'] ?? '')) ?>">
</label>
<h3 class="section-title mt-16"><?= $e($t('settings.company.section_bank')) ?></h3>
<div class="form-grid-2 mt-12">

View File

@@ -34,6 +34,10 @@ use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Settings\ShopproStatusMappingRepository;
use App\Modules\Settings\CompanySettingsController;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigController;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Accounting\ReceiptController;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Settings\CronSettingsController;
use App\Modules\Settings\SettingsController;
use App\Modules\Shipments\ApaczkaShipmentService;
@@ -53,7 +57,9 @@ return static function (Application $app): void {
$authController = new AuthController($template, $auth, $translator);
$usersController = new UsersController($template, $translator, $auth, $app->users());
$shipmentPackageRepositoryForOrders = new ShipmentPackageRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders);
$receiptConfigRepository = new ReceiptConfigRepository($app->db());
$receiptRepository = new ReceiptRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository);
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses());
$allegroIntegrationRepository = new AllegroIntegrationRepository(
$app->db(),
@@ -171,6 +177,21 @@ return static function (Application $app): void {
$auth,
$companySettingsRepository
);
$receiptConfigController = new ReceiptConfigController(
$template,
$translator,
$auth,
$receiptConfigRepository
);
$receiptController = new ReceiptController(
$template,
$translator,
$auth,
$receiptRepository,
$receiptConfigRepository,
$companySettingsRepository,
new OrdersRepository($app->db())
);
$allegroApiClient = new AllegroApiClient();
$shipmentPackageRepository = new ShipmentPackageRepository($app->db());
$shipmentService = new AllegroShipmentService(
@@ -274,6 +295,12 @@ return static function (Application $app): void {
$router->post('/settings/integrations/shoppro/delivery/save', [$shopproIntegrationsController, 'saveDeliveryMappings'], [$authMiddleware]);
$router->get('/settings/company', [$companySettingsController, 'index'], [$authMiddleware]);
$router->post('/settings/company/save', [$companySettingsController, 'save'], [$authMiddleware]);
$router->get('/settings/accounting', [$receiptConfigController, 'index'], [$authMiddleware]);
$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('/orders/{id}/receipt/create', [$receiptController, 'create'], [$authMiddleware]);
$router->post('/orders/{id}/receipt/store', [$receiptController, 'store'], [$authMiddleware]);
$router->get('/orders/{id}/shipment/prepare', [$shipmentController, 'prepare'], [$authMiddleware]);
$router->post('/orders/{id}/shipment/create', [$shipmentController, 'create'], [$authMiddleware]);
$router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]);

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use Throwable;
final class ReceiptController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders
) {
}
public function create(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('Not found', 404);
}
$configs = array_filter($this->receiptConfigs->listAll(), static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1);
if ($configs === []) {
Flash::set('order.error', $this->translator->get('receipts.create.no_configs'));
return Response::redirect('/orders/' . $orderId);
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$seller = $this->companySettings->getSettings();
$totalGross = 0.0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
$totalGross += $qty * $price;
}
$html = $this->template->render('orders/receipt-create', [
'title' => $this->translator->get('receipts.create.title'),
'activeMenu' => 'orders',
'activeOrders' => 'list',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'orderId' => $orderId,
'order' => $order,
'items' => $items,
'configs' => array_values($configs),
'seller' => $seller,
'totalGross' => $totalGross,
], 'layouts/app');
return Response::html($html);
}
public function store(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/orders/' . $orderId);
}
$configId = (int) $request->input('config_id', '0');
if ($configId <= 0) {
Flash::set('order.error', $this->translator->get('receipts.create.no_config_selected'));
return Response::redirect('/orders/' . $orderId . '/receipt/create');
}
$config = $this->receiptConfigs->findById($configId);
if ($config === null || (int) ($config['is_active'] ?? 0) !== 1) {
Flash::set('order.error', $this->translator->get('receipts.create.invalid_config'));
return Response::redirect('/orders/' . $orderId . '/receipt/create');
}
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('Not found', 404);
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
$seller = $this->companySettings->getSettings();
$sellerSnapshot = [
'company_name' => $seller['company_name'] ?? '',
'tax_number' => $seller['tax_number'] ?? '',
'street' => $seller['street'] ?? '',
'city' => $seller['city'] ?? '',
'postal_code' => $seller['postal_code'] ?? '',
'phone' => $seller['phone'] ?? '',
'email' => $seller['email'] ?? '',
'bank_account' => $seller['bank_account'] ?? '',
'bdo_number' => $seller['bdo_number'] ?? '',
'regon' => $seller['regon'] ?? '',
'court_register' => $seller['court_register'] ?? '',
];
$buyerAddress = $this->resolveBuyerAddress($addresses);
$buyerSnapshot = $buyerAddress !== null ? [
'name' => $buyerAddress['name'] ?? '',
'company_name' => $buyerAddress['company_name'] ?? '',
'tax_number' => $buyerAddress['company_tax_number'] ?? '',
'street' => trim(($buyerAddress['street_name'] ?? '') . ' ' . ($buyerAddress['street_number'] ?? '')),
'city' => $buyerAddress['city'] ?? '',
'postal_code' => $buyerAddress['zip_code'] ?? '',
'phone' => $buyerAddress['phone'] ?? '',
'email' => $buyerAddress['email'] ?? '',
] : null;
$itemsSnapshot = [];
$totalGross = 0.0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
$lineTotal = $qty * $price;
$totalGross += $lineTotal;
$itemsSnapshot[] = [
'name' => $item['original_name'] ?? '',
'quantity' => $qty,
'price' => $price,
'total' => $lineTotal,
'sku' => $item['sku'] ?? '',
'ean' => $item['ean'] ?? '',
];
}
$issueDate = trim((string) $request->input('issue_date', ''));
if ($issueDate === '' || strtotime($issueDate) === false) {
$issueDate = date('Y-m-d');
}
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
$orderReference = $this->resolveOrderReference($config, $order);
try {
$receiptNumber = $this->receipts->getNextNumber(
$configId,
(string) ($config['number_format'] ?? 'PAR/%N/%M/%Y'),
(string) ($config['numbering_type'] ?? 'monthly')
);
$user = $this->auth->user();
$this->receipts->create([
'order_id' => $orderId,
'config_id' => $configId,
'receipt_number' => $receiptNumber,
'issue_date' => $issueDate,
'sale_date' => $saleDate,
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
'total_net' => number_format($totalGross, 2, '.', ''),
'total_gross' => number_format($totalGross, 2, '.', ''),
'order_reference_value' => $orderReference,
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
]);
$userName = is_array($user) ? (string) ($user['username'] ?? $user['email'] ?? '') : '';
$this->orders->recordActivity(
$orderId,
'receipt_issued',
'Wystawiono paragon: ' . $receiptNumber,
['receipt_number' => $receiptNumber, 'config_id' => $configId, 'total_gross' => number_format($totalGross, 2, '.', '')],
'user',
$userName !== '' ? $userName : null
);
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
} catch (Throwable) {
Flash::set('order.error', 'Blad wystawiania paragonu');
}
return Response::redirect('/orders/' . $orderId);
}
/**
* @param list<array<string, mixed>> $addresses
* @return array<string, mixed>|null
*/
private function resolveBuyerAddress(array $addresses): ?array
{
$byType = [];
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type !== '' && !isset($byType[$type])) {
$byType[$type] = $addr;
}
}
return $byType['invoice'] ?? $byType['customer'] ?? null;
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments
*/
private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string
{
$source = (string) ($config['sale_date_source'] ?? 'issue_date');
if ($source === 'order_date') {
$ordered = (string) ($order['ordered_at'] ?? '');
if ($ordered !== '') {
$ts = strtotime($ordered);
return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
}
}
if ($source === 'payment_date' && $payments !== []) {
$lastPayment = $payments[0] ?? [];
$payDate = (string) ($lastPayment['payment_date'] ?? '');
if ($payDate !== '') {
$ts = strtotime($payDate);
return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
}
}
return $issueDate;
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
*/
private function resolveOrderReference(array $config, array $order): ?string
{
$ref = (string) ($config['order_reference'] ?? 'none');
if ($ref === 'orderpro') {
return (string) ($order['internal_order_number'] ?? '');
}
if ($ref === 'integration') {
return (string) ($order['external_order_id'] ?? '');
}
return null;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use PDO;
final class ReceiptRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function findByOrderId(int $orderId): array
{
$statement = $this->pdo->prepare(
'SELECT r.*, rc.name AS config_name
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
WHERE r.order_id = :order_id
ORDER BY r.created_at DESC'
);
$statement->execute(['order_id' => $orderId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM receipts WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): int
{
$statement = $this->pdo->prepare(
'INSERT INTO receipts (
order_id, config_id, receipt_number, issue_date, sale_date,
seller_data_json, buyer_data_json, items_json,
total_net, total_gross, order_reference_value, created_by
) VALUES (
:order_id, :config_id, :receipt_number, :issue_date, :sale_date,
:seller_data_json, :buyer_data_json, :items_json,
:total_net, :total_gross, :order_reference_value, :created_by
)'
);
$statement->execute([
'order_id' => (int) $data['order_id'],
'config_id' => (int) $data['config_id'],
'receipt_number' => (string) $data['receipt_number'],
'issue_date' => (string) $data['issue_date'],
'sale_date' => (string) $data['sale_date'],
'seller_data_json' => (string) $data['seller_data_json'],
'buyer_data_json' => $data['buyer_data_json'],
'items_json' => (string) $data['items_json'],
'total_net' => (string) $data['total_net'],
'total_gross' => (string) $data['total_gross'],
'order_reference_value' => $data['order_reference_value'] ?? null,
'created_by' => $data['created_by'] ?? null,
]);
return (int) $this->pdo->lastInsertId();
}
public function getNextNumber(int $configId, string $numberFormat, string $numberingType): string
{
$year = (int) date('Y');
$month = $numberingType === 'yearly' ? null : (int) date('n');
if ($month === null) {
$this->pdo->prepare(
'INSERT INTO receipt_number_counters (config_id, year, month, last_number)
VALUES (:config_id, :year, NULL, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
)->execute(['config_id' => $configId, 'year' => $year]);
$stmt = $this->pdo->prepare(
'SELECT last_number FROM receipt_number_counters
WHERE config_id = :config_id AND year = :year AND month IS NULL'
);
$stmt->execute(['config_id' => $configId, 'year' => $year]);
} else {
$this->pdo->prepare(
'INSERT INTO receipt_number_counters (config_id, year, month, last_number)
VALUES (:config_id, :year, :month, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
)->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
$stmt = $this->pdo->prepare(
'SELECT last_number FROM receipt_number_counters
WHERE config_id = :config_id AND year = :year AND month = :month'
);
$stmt->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
}
$lastNumber = (int) $stmt->fetchColumn();
$number = str_replace(
['%N', '%M', '%Y'],
[str_pad((string) $lastNumber, 3, '0', STR_PAD_LEFT), str_pad((string) ($month ?? 1), 2, '0', STR_PAD_LEFT), (string) $year],
$numberFormat
);
return $number;
}
}

View File

@@ -10,7 +10,9 @@ use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Core\Support\Flash;
use App\Core\Support\StringHelper;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Auth\AuthService;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
final class OrdersController
@@ -20,7 +22,9 @@ final class OrdersController
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersRepository $orders,
private readonly ?ShipmentPackageRepository $shipmentPackages = null
private readonly ?ShipmentPackageRepository $shipmentPackages = null,
private readonly ?ReceiptRepository $receiptRepo = null,
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null
) {
}
@@ -162,6 +166,18 @@ final class OrdersController
? $this->shipmentPackages->findByOrderId($orderId)
: [];
$receipts = $this->receiptRepo !== null
? $this->receiptRepo->findByOrderId($orderId)
: [];
$activeReceiptConfigs = [];
if ($this->receiptConfigRepo !== null) {
$activeReceiptConfigs = array_filter(
$this->receiptConfigRepo->listAll(),
static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1
);
}
$flashSuccess = (string) Flash::get('order.success', '');
$flashError = (string) Flash::get('order.error', '');
@@ -188,6 +204,8 @@ final class OrdersController
'currentStatusCode' => $statusCode,
'flashSuccess' => $flashSuccess,
'flashError' => $flashError,
'receipts' => $receipts,
'receiptConfigs' => $activeReceiptConfigs,
], 'layouts/app');
return Response::html($html);

View File

@@ -44,6 +44,10 @@ final class CompanySettingsRepository
'tax_number' => trim((string) ($row['tax_number'] ?? '')),
'bank_account' => trim((string) ($row['bank_account'] ?? '')),
'bank_owner_name' => trim((string) ($row['bank_owner_name'] ?? '')),
'bdo_number' => trim((string) ($row['bdo_number'] ?? '')),
'regon' => trim((string) ($row['regon'] ?? '')),
'court_register' => trim((string) ($row['court_register'] ?? '')),
'logo_path' => trim((string) ($row['logo_path'] ?? '')),
'default_package_length_cm' => (float) ($row['default_package_length_cm'] ?? 25.0),
'default_package_width_cm' => (float) ($row['default_package_width_cm'] ?? 20.0),
'default_package_height_cm' => (float) ($row['default_package_height_cm'] ?? 8.0),
@@ -73,6 +77,10 @@ final class CompanySettingsRepository
tax_number = :tax_number,
bank_account = :bank_account,
bank_owner_name = :bank_owner_name,
bdo_number = :bdo_number,
regon = :regon,
court_register = :court_register,
logo_path = :logo_path,
default_package_length_cm = :length,
default_package_width_cm = :width,
default_package_height_cm = :height,
@@ -95,6 +103,10 @@ final class CompanySettingsRepository
'tax_number' => StringHelper::nullableString((string) ($data['tax_number'] ?? '')),
'bank_account' => StringHelper::nullableString((string) ($data['bank_account'] ?? '')),
'bank_owner_name' => StringHelper::nullableString((string) ($data['bank_owner_name'] ?? '')),
'bdo_number' => StringHelper::nullableString((string) ($data['bdo_number'] ?? '')),
'regon' => StringHelper::nullableString((string) ($data['regon'] ?? '')),
'court_register' => StringHelper::nullableString((string) ($data['court_register'] ?? '')),
'logo_path' => StringHelper::nullableString((string) ($data['logo_path'] ?? '')),
'length' => max(0.1, (float) ($data['default_package_length_cm'] ?? 25.0)),
'width' => max(0.1, (float) ($data['default_package_width_cm'] ?? 20.0)),
'height' => max(0.1, (float) ($data['default_package_height_cm'] ?? 8.0)),
@@ -151,6 +163,10 @@ final class CompanySettingsRepository
'tax_number' => '',
'bank_account' => '',
'bank_owner_name' => '',
'bdo_number' => '',
'regon' => '',
'court_register' => '',
'logo_path' => '',
'default_package_length_cm' => 25.0,
'default_package_width_cm' => 20.0,
'default_package_height_cm' => 8.0,

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class ReceiptConfigController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ReceiptConfigRepository $repository
) {
}
public function index(Request $request): Response
{
$t = $this->translator;
$configs = $this->repository->listAll();
$editConfig = null;
$editId = (int) $request->input('edit', '0');
if ($editId > 0) {
$editConfig = $this->repository->findById($editId);
}
$html = $this->template->render('settings/accounting', [
'title' => $t->get('settings.accounting.title'),
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'configs' => $configs,
'editConfig' => $editConfig,
'successMessage' => Flash::get('settings.accounting.success', ''),
'errorMessage' => Flash::get('settings.accounting.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
}
$name = trim((string) $request->input('name', ''));
$numberFormat = trim((string) $request->input('number_format', ''));
if ($name === '') {
Flash::set('settings.accounting.error', 'Nazwa konfiguracji jest wymagana');
return Response::redirect('/settings/accounting');
}
if ($numberFormat === '' || strpos($numberFormat, '%N') === false) {
Flash::set('settings.accounting.error', 'Format numeracji jest wymagany i musi zawierac %N');
return Response::redirect('/settings/accounting');
}
try {
$this->repository->save([
'id' => $request->input('id', ''),
'name' => $name,
'is_active' => $request->input('is_active', null),
'number_format' => $numberFormat,
'numbering_type' => $request->input('numbering_type', 'monthly'),
'is_named' => $request->input('is_named', null),
'sale_date_source' => $request->input('sale_date_source', 'issue_date'),
'order_reference' => $request->input('order_reference', 'none'),
]);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.saved'));
} catch (Throwable) {
Flash::set('settings.accounting.error', $this->translator->get('settings.accounting.flash.save_failed'));
}
return Response::redirect('/settings/accounting');
}
public function toggleStatus(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.accounting.error', 'Nieprawidlowy identyfikator konfiguracji');
return Response::redirect('/settings/accounting');
}
try {
$this->repository->toggleStatus($id);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.toggled'));
} catch (Throwable) {
Flash::set('settings.accounting.error', 'Blad zmiany statusu');
}
return Response::redirect('/settings/accounting');
}
public function delete(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.accounting.error', 'Nieprawidlowy identyfikator konfiguracji');
return Response::redirect('/settings/accounting');
}
try {
$this->repository->delete($id);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.deleted'));
} catch (Throwable) {
Flash::set('settings.accounting.error', $this->translator->get('settings.accounting.flash.delete_failed'));
}
return Response::redirect('/settings/accounting');
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use Throwable;
final class ReceiptConfigRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function listAll(): array
{
$statement = $this->pdo->prepare('SELECT * FROM receipt_configs ORDER BY created_at DESC');
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM receipt_configs WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data
*/
public function save(array $data): void
{
$id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : null;
$params = [
'name' => trim((string) ($data['name'] ?? '')),
'is_active' => isset($data['is_active']) ? 1 : 0,
'number_format' => trim((string) ($data['number_format'] ?? 'PAR/%N/%M/%Y')),
'numbering_type' => in_array((string) ($data['numbering_type'] ?? ''), ['monthly', 'yearly'], true)
? (string) $data['numbering_type']
: 'monthly',
'is_named' => isset($data['is_named']) ? 1 : 0,
'sale_date_source' => in_array((string) ($data['sale_date_source'] ?? ''), ['order_date', 'payment_date', 'issue_date'], true)
? (string) $data['sale_date_source']
: 'issue_date',
'order_reference' => in_array((string) ($data['order_reference'] ?? ''), ['none', 'orderpro', 'integration'], true)
? (string) $data['order_reference']
: 'none',
];
if ($id !== null) {
$statement = $this->pdo->prepare(
'UPDATE receipt_configs SET
name = :name,
is_active = :is_active,
number_format = :number_format,
numbering_type = :numbering_type,
is_named = :is_named,
sale_date_source = :sale_date_source,
order_reference = :order_reference
WHERE id = :id'
);
$params['id'] = $id;
} else {
$statement = $this->pdo->prepare(
'INSERT INTO receipt_configs (name, is_active, number_format, numbering_type, is_named, sale_date_source, order_reference)
VALUES (:name, :is_active, :number_format, :numbering_type, :is_named, :sale_date_source, :order_reference)'
);
}
$statement->execute($params);
}
public function toggleStatus(int $id): void
{
$statement = $this->pdo->prepare(
'UPDATE receipt_configs SET is_active = NOT is_active WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM receipt_configs WHERE id = :id');
$statement->execute(['id' => $id]);
}
}