update
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
.scannerwork/
|
.scannerwork/
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1,42 +1,43 @@
|
|||||||
# Roadmap: crmPRO
|
# Roadmap: crmPRO
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Stabilizacja i poprawa jakości kodu crmPRO — identyfikacja i naprawa błędów, analiza jakości kodu przez SonarQube.
|
Stabilizacja i poprawa jakosci kodu crmPRO oraz rozwoj finansow o automatyczny import faktur z Fakturowni.
|
||||||
|
|
||||||
## Current Milestone
|
## Current Milestone
|
||||||
**v0.1 Stabilizacja i jakość kodu** (v0.1.0)
|
**v0.1 Stabilizacja i jakosc kodu + import finansow** (v0.1.0)
|
||||||
Status: In progress
|
Status: In progress
|
||||||
Phases: 1 of 4 complete
|
Phases: 1 of 5 complete
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
| Phase | Name | Plans | Status | Completed |
|
| Phase | Name | Plans | Status | Completed |
|
||||||
|-------|------|-------|--------|-----------|
|
|-------|------|-------|--------|-----------|
|
||||||
| 1 | Konfiguracja SonarQube i baseline | 1/1 | ✅ Complete | 2026-03-15 |
|
| 1 | Konfiguracja SonarQube i baseline | 1/1 | Complete | 2026-03-15 |
|
||||||
| 2 | Naprawa błędów krytycznych | TBD | 🔵 Next | - |
|
| 2 | Naprawa bledow krytycznych | TBD | Next | - |
|
||||||
| 3 | Naprawa błędów głównych | TBD | Not started | - |
|
| 3 | Naprawa bledow glownych | TBD | Not started | - |
|
||||||
| 4 | Poprawa pokrycia testami | TBD | Not started | - |
|
| 4 | Poprawa pokrycia testami | TBD | Not started | - |
|
||||||
|
| 5 | Import finansow z Fakturowni | 0/1 | Planning | - |
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
### Phase 1: Konfiguracja SonarQube i baseline ✅
|
### Phase 1: Konfiguracja SonarQube i baseline
|
||||||
|
|
||||||
**Goal:** Skonfigurować projekt w SonarQube, uruchomić pierwszy skan i uzyskać baseline jakości kodu
|
**Goal:** Skonfigurowac projekt w SonarQube, uruchomic pierwszy skan i uzyskac baseline jakosci kodu
|
||||||
**Depends on:** Nothing (first phase)
|
**Depends on:** Nothing (first phase)
|
||||||
**Completed:** 2026-03-15
|
**Completed:** 2026-03-15
|
||||||
|
|
||||||
**Results:**
|
**Results:**
|
||||||
- sonar-project.properties skonfigurowany
|
- sonar-project.properties skonfigurowany
|
||||||
- Pierwszy skan: 88 plików, 9356 LoC
|
- Pierwszy skan: 88 plikow, 9356 LoC
|
||||||
- Baseline: 58 bugs, 1649 code smells, 0% coverage, 5.6% duplikacji
|
- Baseline: 58 bugs, 1649 code smells, 0% coverage, 5.6% duplikacji
|
||||||
- Reliability: D, Security: A, Maintainability: A
|
- Reliability: D, Security: A, Maintainability: A
|
||||||
|
|
||||||
**Plans:**
|
**Plans:**
|
||||||
- [x] 01-01: Konfiguracja SonarQube i analiza wyników pierwszego skanu
|
- [x] 01-01: Konfiguracja SonarQube i analiza wynikow pierwszego skanu
|
||||||
|
|
||||||
### Phase 2: Naprawa błędów krytycznych
|
### Phase 2: Naprawa bledow krytycznych
|
||||||
|
|
||||||
**Goal:** Naprawić wszystkie bugs i vulnerabilities o priorytecie Critical/Blocker
|
**Goal:** Naprawic wszystkie bugs i vulnerabilities o priorytecie Critical/Blocker
|
||||||
**Depends on:** Phase 1 (wyniki skanu)
|
**Depends on:** Phase 1 (wyniki skanu)
|
||||||
**Research:** Unlikely
|
**Research:** Unlikely
|
||||||
|
|
||||||
@@ -46,11 +47,11 @@ Phases: 1 of 4 complete
|
|||||||
- Reskan po naprawach
|
- Reskan po naprawach
|
||||||
|
|
||||||
**Plans:**
|
**Plans:**
|
||||||
- [ ] 02-01: TBD (na podstawie wyników Phase 1)
|
- [ ] 02-01: TBD (na podstawie wynikow Phase 1)
|
||||||
|
|
||||||
### Phase 3: Naprawa błędów głównych
|
### Phase 3: Naprawa bledow glownych
|
||||||
|
|
||||||
**Goal:** Naprawić bugs o priorytecie Major i code smells wpływające na stabilność
|
**Goal:** Naprawic bugs o priorytecie Major i code smells wplywajace na stabilnosc
|
||||||
**Depends on:** Phase 2
|
**Depends on:** Phase 2
|
||||||
**Research:** Unlikely
|
**Research:** Unlikely
|
||||||
|
|
||||||
@@ -60,21 +61,36 @@ Phases: 1 of 4 complete
|
|||||||
- Reskan po naprawach
|
- Reskan po naprawach
|
||||||
|
|
||||||
**Plans:**
|
**Plans:**
|
||||||
- [ ] 03-01: TBD (na podstawie wyników Phase 2)
|
- [ ] 03-01: TBD (na podstawie wynikow Phase 2)
|
||||||
|
|
||||||
### Phase 4: Poprawa pokrycia testami
|
### Phase 4: Poprawa pokrycia testami
|
||||||
|
|
||||||
**Goal:** Dodać testy dla kluczowych modułów zidentyfikowanych przez SonarQube
|
**Goal:** Dodac testy dla kluczowych modulow zidentyfikowanych przez SonarQube
|
||||||
**Depends on:** Phase 3
|
**Depends on:** Phase 3
|
||||||
**Research:** Unlikely
|
**Research:** Unlikely
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
- Testy dla modułów o najniższym pokryciu
|
- Testy dla modulow o najnizszym pokryciu
|
||||||
- Testy regresji dla naprawionych bugów
|
- Testy regresji dla naprawionych bugow
|
||||||
|
|
||||||
**Plans:**
|
**Plans:**
|
||||||
- [ ] 04-01: TBD (na podstawie wyników Phase 3)
|
- [ ] 04-01: TBD (na podstawie wynikow Phase 3)
|
||||||
|
|
||||||
|
### Phase 5: Import finansow z Fakturowni
|
||||||
|
|
||||||
|
**Goal:** Wdrozyc automatyczny import faktur przychodowych i kosztowych z Fakturowni do finansow crmPRO
|
||||||
|
**Depends on:** None (moze byc realizowane niezaleznie od prac SonarQube)
|
||||||
|
**Research:** Likely (szczegoly endpointow API i semantyka pol dokumentow)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Integracja API Fakturownia (dane z `.env` + data startu importu)
|
||||||
|
- Trwale mapowanie klientow i produktow/uslug do struktur finansowych CRM
|
||||||
|
- Import cykliczny przez cron z idempotencja i obsluga bledow
|
||||||
|
- Panel mapowania brakow w `/finances/main_view/`
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 05-01: Integracja Fakturownia i automatyczny import do finansow
|
||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-03-15*
|
*Roadmap created: 2026-03-15*
|
||||||
*Last updated: 2026-03-15 — Phase 1 complete*
|
*Last updated: 2026-04-02 - Phase 5 planned*
|
||||||
|
|||||||
@@ -1,57 +1,38 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .paul/PROJECT.md (updated 2026-03-15)
|
See: .paul/PROJECT.md (updated 2026-03-15)
|
||||||
|
|
||||||
**Core value:** Użytkownicy mogą efektywnie zarządzać projektami, zadaniami i klientami w jednym systemie CRM
|
**Core value:** Uzytkownicy moga efektywnie zarzadzac projektami, zadaniami i klientami w jednym systemie CRM
|
||||||
**Current focus:** Faza 2 — Naprawa błędów krytycznych
|
**Current focus:** Faza 5 - Import finansow z Fakturowni
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: v0.1 Stabilizacja i jakość kodu
|
Milestone: v0.1 Stabilizacja i jakosc kodu
|
||||||
Phase: 2 of 4 (Naprawa błędów krytycznych) — In Progress
|
Phase: 5 of 5 (Import finansow z Fakturowni) - Apply complete
|
||||||
Plan: 02-02 complete, 02-01 awaiting approval
|
Plan: 05-01 executed, awaiting unify
|
||||||
Status: Loop closed for 02-02, ready for next plan
|
Status: APPLY complete, ready for UNIFY
|
||||||
Last activity: 2026-03-15 — Completed .paul/phases/02-critical-bugs-fix/02-02-SUMMARY.md
|
Last activity: 2026-04-02 11:03 - Executed .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
|
||||||
|
|
||||||
Progress:
|
Progress:
|
||||||
- Milestone: [██░░░░░░░░] 25%
|
- Milestone: [#######---] 35%
|
||||||
- Phase 2: [█████░░░░░] 50% (1/2 plans complete)
|
- Phase 5: [########--] 80%
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
Current loop state:
|
Current loop state:
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN --> APPLY --> UNIFY
|
||||||
✓ ✓ ✓ [Loop complete for 02-02 — ready for next PLAN]
|
✓ ✓ ○ [Apply completed, ready for reconciliation]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Accumulated Context
|
|
||||||
|
|
||||||
### Decisions
|
|
||||||
- 2026-03-15: SonarQube URL = https://sonar.project-pro.pl (nie localhost)
|
|
||||||
- 2026-03-15: Skan automatyczny via sonar-scanner CLI + MCP do odczytu wyników
|
|
||||||
- 2026-03-15: Usunięto nawigację z layout-cron zamiast ukrywania CSS
|
|
||||||
- 2026-03-15: JSON.parse zamiast deprecated jQuery.parseJSON
|
|
||||||
|
|
||||||
### Deferred Issues
|
|
||||||
None yet.
|
|
||||||
|
|
||||||
### Blockers/Concerns
|
|
||||||
None.
|
|
||||||
|
|
||||||
### Git State
|
|
||||||
Last commit: e92c9fe
|
|
||||||
Branch: main
|
|
||||||
Feature branches merged: none
|
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-15
|
Last session: 2026-04-02 11:03
|
||||||
Stopped at: Plan 02-02 UNIFY complete
|
Stopped at: Apply finished for plan 05-01
|
||||||
Next action: Approve and execute plan 02-01 (SonarQube bugs fix) — run /paul:apply .paul/phases/02-critical-bugs-fix/02-01-PLAN.md
|
Next action: Run $paul-unify .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
|
||||||
Resume file: .paul/phases/02-critical-bugs-fix/02-02-SUMMARY.md
|
Resume file: .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
|
||||||
|
|
||||||
---
|
---
|
||||||
*STATE.md — Updated after every significant action*
|
*STATE.md - Updated after every significant action*
|
||||||
|
|||||||
223
.paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
Normal file
223
.paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
phase: 05-finances-fakturownia-import
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- cron.php
|
||||||
|
- autoload/class.Cron.php
|
||||||
|
- autoload/Controllers/FinancesController.php
|
||||||
|
- autoload/Domain/Finances/FinanceRepository.php
|
||||||
|
- autoload/Domain/Finances/FakturowniaApiClient.php
|
||||||
|
- autoload/Domain/Finances/FakturowniaImportRepository.php
|
||||||
|
- autoload/Domain/Finances/FakturowniaInvoiceImporter.php
|
||||||
|
- templates/finances/main-view.php
|
||||||
|
- templates/finances/fakturownia-import-panel.php
|
||||||
|
- .env.example
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Goal
|
||||||
|
Dodac automatyczny import faktur przychodowych i kosztowych z Fakturowni do finansow crmPRO, z mechanizmem uczenia mapowan klientow i produktow/uslug.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Zminimalizowac reczna prace w finansach: pierwsze faktury wymagaja mapowania, ale kolejne dokumenty z tymi samymi klientami i pozycjami maja importowac sie juz calkowicie automatycznie.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Warstwa integracji API Fakturowni z konfiguracja przez `.env`
|
||||||
|
- Trwale mapowania klientow i produktow/uslug do struktur finansowych crmPRO
|
||||||
|
- Automatyczny import przez cron z idempotencja i data startu
|
||||||
|
- Panel w finansach do obslugi brakujacych mapowan
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Project Context
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/ROADMAP.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
|
||||||
|
## Source Files
|
||||||
|
@cron.php
|
||||||
|
@autoload/class.Cron.php
|
||||||
|
@autoload/Controllers/FinancesController.php
|
||||||
|
@autoload/Domain/Finances/FinanceRepository.php
|
||||||
|
@templates/finances/main-view.php
|
||||||
|
@config.php
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Konfiguracja API i data startu importu
|
||||||
|
```gherkin
|
||||||
|
Given w pliku .env ustawiono dane dostepowe Fakturowni oraz date startu importu
|
||||||
|
When cron uruchamia proces importu
|
||||||
|
Then importer laczy sie z API Fakturowni i pobiera tylko dokumenty od skonfigurowanej daty
|
||||||
|
And brakujaca konfiguracja zwraca czytelny blad bez przerwania calego crona
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: Import przychodow i kosztow z idempotencja
|
||||||
|
```gherkin
|
||||||
|
Given faktura przychodowa lub kosztowa istnieje w Fakturowni
|
||||||
|
When dokument ma komplet mapowan klienta i pozycji
|
||||||
|
Then powstaje operacja finansowa w crmPRO z poprawnym znakiem kwoty (przychod > 0, koszt < 0)
|
||||||
|
And ponowne uruchomienie importu nie tworzy duplikatu tej samej faktury
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: Mechanizm uczenia mapowan
|
||||||
|
```gherkin
|
||||||
|
Given dokument zawiera klienta lub pozycje bez mapowania
|
||||||
|
When importer przetwarza dokument
|
||||||
|
Then dokument nie jest importowany do finansow, a brakujace mapowania trafiaja na liste do recznego przypisania
|
||||||
|
And po zapisaniu mapowania kolejne faktury z tym klientem/pozycja importuja sie automatycznie
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-4: Obsluga mapowan w zakladce finansow
|
||||||
|
```gherkin
|
||||||
|
Given uzytkownik jest na /finances/main_view/
|
||||||
|
When otwiera panel importu Fakturownia
|
||||||
|
Then widzi brakujace mapowania klientow i produktow/uslug
|
||||||
|
And moze przypisac klienta CRM i kategorie finansowa
|
||||||
|
And zapis mapowania jest chroniony tokenem CSRF
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Zbudowac warstwe integracji Fakturownia + trwawe mapowania</name>
|
||||||
|
<files>
|
||||||
|
autoload/Domain/Finances/FakturowniaApiClient.php,
|
||||||
|
autoload/Domain/Finances/FakturowniaImportRepository.php,
|
||||||
|
autoload/Domain/Finances/FinanceRepository.php,
|
||||||
|
.env.example
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Dodac loader `.env` (bez nowych bibliotek) i zdefiniowac klucze:
|
||||||
|
- `FAKTUROWNIA_API_DOMAIN`
|
||||||
|
- `FAKTUROWNIA_API_TOKEN`
|
||||||
|
- `FAKTUROWNIA_START_DATE`
|
||||||
|
- `FAKTUROWNIA_PAGE_LIMIT` (opcjonalnie)
|
||||||
|
2. Dodac klienta API Fakturowni z obsluga paginacji i timeoutow:
|
||||||
|
- pobieranie faktur przychodowych (np. endpoint invoices)
|
||||||
|
- pobieranie faktur kosztowych (np. endpoint costs/expenses)
|
||||||
|
3. Dodac repozytorium importu z tabelami tworzonymi przez `CREATE TABLE IF NOT EXISTS`:
|
||||||
|
- tabela mapowania klientow Fakturownia -> crm_client.id
|
||||||
|
- tabela mapowania pozycji (nazwa produktu/uslugi) -> finance_categories.id
|
||||||
|
- tabela stanu importu i idempotencji (external_id + typ dokumentu)
|
||||||
|
- tabela kolejki brakujacych mapowan do panelu finansow
|
||||||
|
4. Wszystkie zapytania SQL wykonywac przez medoo/query z parametrami bindowanymi (bez sklejania SQL z danymi wejsciowymi).
|
||||||
|
5. W `FinanceRepository` dodac metody pomocnicze do pobierania kategorii i klientow dla panelu mapowan.
|
||||||
|
|
||||||
|
Unikac: trzymania tokenu API w `config.php`, mapowan w sesji i importu bez kontroli duplikatow.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- Odczyt `.env.example` potwierdza nowe klucze integracji.
|
||||||
|
- Odczyt nowych klas potwierdza osobne odpowiedzialnosci (API, repo mapowan, state importu).
|
||||||
|
- Test manualny: dwukrotne wywolanie zapisu importu dla tego samego external_id nie tworzy duplikatu.
|
||||||
|
</verify>
|
||||||
|
<done>AC-1 i AC-2 i AC-3 satisfied: konfiguracja, idempotencja i struktury mapowan gotowe</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Dodac importer dokumentow i podpiac go pod cron</name>
|
||||||
|
<files>
|
||||||
|
autoload/Domain/Finances/FakturowniaInvoiceImporter.php,
|
||||||
|
autoload/class.Cron.php,
|
||||||
|
cron.php
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Dodac serwis importera, ktory:
|
||||||
|
- pobiera dokumenty od `FAKTUROWNIA_START_DATE`,
|
||||||
|
- scala dane klienta i pozycji,
|
||||||
|
- sprawdza mapowania klienta i pozycji,
|
||||||
|
- przy komplecie mapowan zapisuje operacje finansowe,
|
||||||
|
- przy brakach wpisuje rekordy do kolejki brakow mapowan i pomija import dokumentu.
|
||||||
|
2. Dodac polityke znaku kwoty:
|
||||||
|
- przychodowe -> dodatnie `amount`
|
||||||
|
- kosztowe -> ujemne `amount`
|
||||||
|
3. Wprowadzic idempotencje na poziomie zrodlowego `external_id` + typ dokumentu.
|
||||||
|
4. Rozszerzyc `Cron` i `cron.php` o krok `import_finances_from_fakturownia()`:
|
||||||
|
- uruchamiany przy kazdym cyklu crona,
|
||||||
|
- zwraca statusy `ok|empty|error` analogicznie do obecnych zadan,
|
||||||
|
- loguje krotki komunikat o liczbie zaimportowanych i pominietych dokumentow.
|
||||||
|
|
||||||
|
Unikac: przerywania calego crona przy pojedynczym bledzie faktury.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- Suchy przebieg importera zwraca licznik imported/skipped/unmapped.
|
||||||
|
- Uruchomienie `php cron.php` wykonuje nowy krok i nie psuje istniejacych zadan cron.
|
||||||
|
- Powtorne uruchomienie dla tych samych danych nie dubluje wpisow w `finance_operations`.
|
||||||
|
</verify>
|
||||||
|
<done>AC-1 i AC-2 i AC-3 satisfied: cron importuje faktury cyklicznie i bez duplikatow</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Dodac panel mapowan Fakturowni w finansach i zapis mapowan</name>
|
||||||
|
<files>
|
||||||
|
autoload/Controllers/FinancesController.php,
|
||||||
|
templates/finances/main-view.php,
|
||||||
|
templates/finances/fakturownia-import-panel.php
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Rozszerzyc `FinancesController` o akcje:
|
||||||
|
- pobranie brakujacych mapowan do widoku finansow,
|
||||||
|
- zapis mapowania klienta,
|
||||||
|
- zapis mapowania produktu/uslugi do kategorii,
|
||||||
|
- wszystkie zapisy z walidacja i `S::csrf_verify()`.
|
||||||
|
2. W `templates/finances/main-view.php` dolaczyc panel importu (partial) pokazujacy:
|
||||||
|
- liste nieprzypisanych klientow z Fakturowni + select klienta CRM,
|
||||||
|
- liste nieprzypisanych pozycji + select kategorii finansowej,
|
||||||
|
- status ostatniego importu (czas, liczba imported/skipped/unmapped).
|
||||||
|
3. W panelu uzyc escapingu danych wyjsciowych (np. `htmlspecialchars`) i komunikatow zgodnych z UI crmPRO.
|
||||||
|
|
||||||
|
Unikac: logiki biznesowej bezposrednio w widoku.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- Wejscie na `/finances/main_view/` pokazuje panel mapowan bez regresji istniejacych statystyk.
|
||||||
|
- Zapis mapowania bez tokenu CSRF jest odrzucany.
|
||||||
|
- Po zapisaniu mapowania i ponownym imporcie dokument znika z listy brakow i tworzy operacje automatycznie.
|
||||||
|
</verify>
|
||||||
|
<done>AC-3 i AC-4 satisfied: mapowania sa obslugiwane z UI i kolejne faktury wpadaja automatycznie</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## DO NOT CHANGE
|
||||||
|
- modules niezwiązane z finansami (tasks, projects, wiki)
|
||||||
|
- mechanizm logowania i sesji poza niezbednym CSRF check
|
||||||
|
- istniejace endpointy email importera zadan
|
||||||
|
|
||||||
|
## SCOPE LIMITS
|
||||||
|
- Ten plan obejmuje tylko import i mapowania Fakturownia -> Finanse
|
||||||
|
- Bez refaktoryzacji calego modulu finansow
|
||||||
|
- Bez zmian w zewnetrznym harmonogramie systemowym (zakladamy, ze cron systemowy juz wywoluje `cron.php`)
|
||||||
|
- Bez automatycznego tworzenia nowych klientow CRM (tylko mapowanie do istniejacych)
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] `.env.example` zawiera komplet nowych zmiennych Fakturowni
|
||||||
|
- [ ] Importer obsluguje dokumenty przychodowe i kosztowe
|
||||||
|
- [ ] Idempotencja blokuje duplikaty importu
|
||||||
|
- [ ] Brakujace mapowania sa kolejkowane i widoczne w finansach
|
||||||
|
- [ ] Zapis mapowan wymaga poprawnego tokenu CSRF
|
||||||
|
- [ ] `php cron.php` wykonuje import bez zatrzymywania pozostalych krokow
|
||||||
|
- [ ] All acceptance criteria met
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Faktury przychodowe i kosztowe importuja sie automatycznie po uzupelnieniu mapowan
|
||||||
|
- Pierwszy import nie robi blednych przypisan: nieznane rekordy trafiaja do kolejki mapowania
|
||||||
|
- Kolejne dokumenty z tym samym klientem i pozycja sa przypisywane bez interwencji recznej
|
||||||
|
- Import uruchamia sie cyklicznie przez cron i jest bezpieczny na ponowne wykonanie
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/05-finances-fakturownia-import/05-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
18
.vscode/ftp-kr.sync.cache.json
vendored
18
.vscode/ftp-kr.sync.cache.json
vendored
@@ -681,8 +681,8 @@
|
|||||||
},
|
},
|
||||||
"main_view.php": {
|
"main_view.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 47213,
|
"size": 49131,
|
||||||
"lmtime": 1773530856493,
|
"lmtime": 1774532986644,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"task_edit.php": {
|
"task_edit.php": {
|
||||||
@@ -693,9 +693,9 @@
|
|||||||
},
|
},
|
||||||
"task_popup.php": {
|
"task_popup.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 42366,
|
"size": 45012,
|
||||||
"lmtime": 1773530848546,
|
"lmtime": 1773530848546,
|
||||||
"modified": false
|
"modified": true
|
||||||
},
|
},
|
||||||
"task_single.php": {
|
"task_single.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
@@ -703,11 +703,17 @@
|
|||||||
"lmtime": 1772276304856,
|
"lmtime": 1772276304856,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
|
"task_work_logs_popup.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2629,
|
||||||
|
"lmtime": 0,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
"work-time.php": {
|
"work-time.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 12763,
|
"size": 15966,
|
||||||
"lmtime": 1771236164971,
|
"lmtime": 1771236164971,
|
||||||
"modified": false
|
"modified": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
|
|||||||
47
__tmp_count.php
Normal file
47
__tmp_count.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
require 'autoload/class.Env.php';
|
||||||
|
Env::load('.env');
|
||||||
|
$domain = Env::get('FAKTUROWNIA_API_DOMAIN');
|
||||||
|
$token = Env::get('FAKTUROWNIA_API_TOKEN');
|
||||||
|
$start = Env::get('FAKTUROWNIA_START_DATE');
|
||||||
|
$base = strpos($domain, 'http') === 0 ? $domain : 'https://' . $domain;
|
||||||
|
|
||||||
|
function fetchAll($urlBase) {
|
||||||
|
$all = [];
|
||||||
|
for ($page = 1; $page <= 50; $page++) {
|
||||||
|
$url = $urlBase . '&page=' . $page . '&per_page=100';
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
$arr = json_decode((string)$body, true);
|
||||||
|
if (!is_array($arr) || count($arr) === 0) break;
|
||||||
|
foreach ($arr as $row) $all[] = $row;
|
||||||
|
if (count($arr) < 100) break;
|
||||||
|
}
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(array $doc): string {
|
||||||
|
$date = (string)($doc['issue_date'] ?? $doc['sell_date'] ?? $doc['created_at'] ?? '');
|
||||||
|
return substr($date, 0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sales = fetchAll($base . '/invoices.json?api_token=' . urlencode($token));
|
||||||
|
$costs = fetchAll($base . '/invoices.json?api_token=' . urlencode($token) . '&income=no');
|
||||||
|
|
||||||
|
$sf = [];
|
||||||
|
foreach ($sales as $doc) {
|
||||||
|
$date = normalizeDate($doc);
|
||||||
|
if ($date && strtotime($date) >= strtotime($start) && (($doc['income'] ?? true) !== false)) $sf[] = $doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cf = [];
|
||||||
|
foreach ($costs as $doc) {
|
||||||
|
$date = normalizeDate($doc);
|
||||||
|
if ($date && strtotime($date) >= strtotime($start)) $cf[] = $doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo 'sales_from_start(issue_date_first)=' . count($sf) . PHP_EOL;
|
||||||
|
echo 'costs_from_start(issue_date_first)=' . count($cf) . PHP_EOL;
|
||||||
54
__tmp_diag.php
Normal file
54
__tmp_diag.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
require 'autoload/class.Env.php';
|
||||||
|
Env::load('.env');
|
||||||
|
$domain = Env::get('FAKTUROWNIA_API_DOMAIN');
|
||||||
|
$token = Env::get('FAKTUROWNIA_API_TOKEN');
|
||||||
|
$start = Env::get('FAKTUROWNIA_START_DATE');
|
||||||
|
$base = strpos($domain, 'http') === 0 ? $domain : 'https://' . $domain;
|
||||||
|
|
||||||
|
function fetchAll($urlBase) {
|
||||||
|
$all = [];
|
||||||
|
for ($page = 1; $page <= 50; $page++) {
|
||||||
|
$url = $urlBase . '&page=' . $page . '&per_page=100';
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
$arr = json_decode((string)$body, true);
|
||||||
|
if (!is_array($arr) || count($arr) === 0) break;
|
||||||
|
foreach ($arr as $row) $all[] = $row;
|
||||||
|
if (count($arr) < 100) break;
|
||||||
|
}
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sales = fetchAll($base . '/invoices.json?api_token=' . urlencode($token));
|
||||||
|
$costs = fetchAll($base . '/invoices.json?api_token=' . urlencode($token) . '&income=no');
|
||||||
|
|
||||||
|
$sf = [];
|
||||||
|
foreach ($sales as $doc) {
|
||||||
|
$date = isset($doc['sell_date']) && $doc['sell_date'] ? $doc['sell_date'] : (isset($doc['issue_date']) ? $doc['issue_date'] : '');
|
||||||
|
if ($date && strtotime(substr($date,0,10)) >= strtotime($start)) $sf[] = $doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cf = [];
|
||||||
|
foreach ($costs as $doc) {
|
||||||
|
$date = isset($doc['sell_date']) && $doc['sell_date'] ? $doc['sell_date'] : (isset($doc['issue_date']) ? $doc['issue_date'] : '');
|
||||||
|
if ($date && strtotime(substr($date,0,10)) >= strtotime($start)) $cf[] = $doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo 'sales_all=' . count($sales) . PHP_EOL;
|
||||||
|
echo 'costs_all=' . count($costs) . PHP_EOL;
|
||||||
|
echo 'sales_from_start=' . count($sf) . PHP_EOL;
|
||||||
|
echo 'costs_from_start=' . count($cf) . PHP_EOL;
|
||||||
|
|
||||||
|
echo "-- sales_from_start --" . PHP_EOL;
|
||||||
|
foreach ($sf as $d) {
|
||||||
|
echo $d['id'] . ' | ' . ($d['number'] ?? '-') . ' | ' . ($d['sell_date'] ?? '-') . ' | ' . ($d['issue_date'] ?? '-') . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "-- costs_from_start --" . PHP_EOL;
|
||||||
|
foreach ($cf as $d) {
|
||||||
|
echo $d['id'] . ' | ' . ($d['number'] ?? '-') . ' | ' . ($d['sell_date'] ?? '-') . ' | ' . ($d['issue_date'] ?? '-') . PHP_EOL;
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ class FinancesController
|
|||||||
return new \Domain\Finances\FinanceRepository();
|
return new \Domain\Finances\FinanceRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function importRepo()
|
||||||
|
{
|
||||||
|
return new \Domain\Finances\FakturowniaImportRepository();
|
||||||
|
}
|
||||||
|
|
||||||
private static function requireAuth()
|
private static function requireAuth()
|
||||||
{
|
{
|
||||||
global $user;
|
global $user;
|
||||||
@@ -53,6 +58,8 @@ class FinancesController
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
$repo = self::repo();
|
$repo = self::repo();
|
||||||
|
$importRepo = self::importRepo();
|
||||||
|
$importRepo -> ensureTables();
|
||||||
|
|
||||||
if ( \S::get( 'tag-clear' ) )
|
if ( \S::get( 'tag-clear' ) )
|
||||||
unset( $_SESSION['finance-tag-id'] );
|
unset( $_SESSION['finance-tag-id'] );
|
||||||
@@ -93,10 +100,121 @@ class FinancesController
|
|||||||
'wallet_summary' => $repo -> walletSummary( $group_id ),
|
'wallet_summary' => $repo -> walletSummary( $group_id ),
|
||||||
'wallet_summary_this_month' => $repo -> walletSummaryThisMonth( $group_id ),
|
'wallet_summary_this_month' => $repo -> walletSummaryThisMonth( $group_id ),
|
||||||
'wallet_income_this_month' => $repo -> walletIncomeThisMonth( $group_id ),
|
'wallet_income_this_month' => $repo -> walletIncomeThisMonth( $group_id ),
|
||||||
'wallet_expenses_this_month' => $repo -> walletExpensesThisMonth( $group_id )
|
'wallet_expenses_this_month' => $repo -> walletExpensesThisMonth( $group_id ),
|
||||||
|
'fakturownia_pending_clients' => self::preparePendingRows( $importRepo -> pendingClientMappings() ),
|
||||||
|
'fakturownia_pending_items' => self::preparePendingRows( $importRepo -> pendingItemMappings() ),
|
||||||
|
'fakturownia_crm_clients' => $repo -> clientsList(),
|
||||||
|
'fakturownia_categories' => self::prepareCategoryOptions( $repo -> categoriesFlatList() ),
|
||||||
|
'fakturownia_last_summary' => self::lastImportSummary( $importRepo )
|
||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function preparePendingRows( $rows )
|
||||||
|
{
|
||||||
|
$output = [];
|
||||||
|
|
||||||
|
if ( !is_array( $rows ) )
|
||||||
|
return $output;
|
||||||
|
|
||||||
|
foreach ( $rows as $row )
|
||||||
|
{
|
||||||
|
$payload = [];
|
||||||
|
if ( isset( $row['payload_json'] ) && $row['payload_json'] )
|
||||||
|
{
|
||||||
|
$decoded = json_decode( $row['payload_json'], true );
|
||||||
|
if ( is_array( $decoded ) )
|
||||||
|
$payload = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output[] = [
|
||||||
|
'external_key' => (string)$row['external_key'],
|
||||||
|
'external_name' => (string)$row['external_name'],
|
||||||
|
'hits' => (int)$row['hits'],
|
||||||
|
'last_seen_at' => (string)$row['last_seen_at'],
|
||||||
|
'payload' => $payload
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lastImportSummary( $importRepo )
|
||||||
|
{
|
||||||
|
$raw = $importRepo -> getState( 'last_import_summary' );
|
||||||
|
if ( !$raw )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
$decoded = json_decode( $raw, true );
|
||||||
|
return is_array( $decoded ) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function prepareCategoryOptions( $categories )
|
||||||
|
{
|
||||||
|
$options = [];
|
||||||
|
if ( !is_array( $categories ) || empty( $categories ) )
|
||||||
|
return $options;
|
||||||
|
|
||||||
|
$byId = [];
|
||||||
|
foreach ( $categories as $category )
|
||||||
|
{
|
||||||
|
$id = isset( $category['id'] ) ? (int)$category['id'] : 0;
|
||||||
|
if ( $id <= 0 )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$byId[ $id ] = $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $categories as $category )
|
||||||
|
{
|
||||||
|
$id = isset( $category['id'] ) ? (int)$category['id'] : 0;
|
||||||
|
if ( $id <= 0 )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$label = self::buildCategoryPathLabel( $id, $byId );
|
||||||
|
$options[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $label,
|
||||||
|
'group_id' => isset( $category['group_id'] ) ? (int)$category['group_id'] : 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort( $options, function( $left, $right )
|
||||||
|
{
|
||||||
|
return strcmp( $left['name'], $right['name'] );
|
||||||
|
} );
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildCategoryPathLabel( $categoryId, $byId )
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$guard = 0;
|
||||||
|
$currentId = (int)$categoryId;
|
||||||
|
|
||||||
|
while ( $currentId > 0 && isset( $byId[ $currentId ] ) )
|
||||||
|
{
|
||||||
|
$category = $byId[ $currentId ];
|
||||||
|
$name = trim( (string)( $category['name'] ?? '' ) );
|
||||||
|
if ( $name !== '' )
|
||||||
|
array_unshift( $parts, $name );
|
||||||
|
|
||||||
|
$parentId = isset( $category['parent_id'] ) ? (int)$category['parent_id'] : 0;
|
||||||
|
if ( $parentId <= 0 || $parentId === $currentId )
|
||||||
|
break;
|
||||||
|
|
||||||
|
$currentId = $parentId;
|
||||||
|
$guard++;
|
||||||
|
if ( $guard > 20 )
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $parts ) )
|
||||||
|
return 'Kategoria #' . (int)$categoryId;
|
||||||
|
|
||||||
|
return implode( ' > ', $parts );
|
||||||
|
}
|
||||||
|
|
||||||
public static function operationEdit()
|
public static function operationEdit()
|
||||||
{
|
{
|
||||||
if ( !self::requireAuth() )
|
if ( !self::requireAuth() )
|
||||||
@@ -230,4 +348,70 @@ class FinancesController
|
|||||||
'date_to' => $date_to
|
'date_to' => $date_to
|
||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function fakturowniaClientMappingSave()
|
||||||
|
{
|
||||||
|
if ( !self::requireAuth() )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ( !\S::csrf_verify() )
|
||||||
|
{
|
||||||
|
\S::alert( 'Nieprawidlowy token bezpieczenstwa. Odswiez strone i sproboj ponownie.' );
|
||||||
|
header( 'Location: /finances/main_view/' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalKey = trim( (string)\S::get( 'external_key' ) );
|
||||||
|
$externalName = trim( (string)\S::get( 'external_name' ) );
|
||||||
|
$crmClientId = (int)\S::get( 'crm_client_id' );
|
||||||
|
$repo = self::repo();
|
||||||
|
|
||||||
|
if ( $externalKey === '' || $externalName === '' || $crmClientId <= 0 || !$repo -> clientExists( $crmClientId ) )
|
||||||
|
{
|
||||||
|
\S::alert( 'Nie udalo sie zapisac mapowania klienta. Uzupelnij wszystkie pola.' );
|
||||||
|
header( 'Location: /finances/main_view/' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$importRepo = self::importRepo();
|
||||||
|
$importRepo -> ensureTables();
|
||||||
|
$importRepo -> saveClientMapping( $externalKey, $externalName, $crmClientId );
|
||||||
|
|
||||||
|
\S::alert( 'Mapowanie klienta zostalo zapisane.' );
|
||||||
|
header( 'Location: /finances/main_view/' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fakturowniaItemMappingSave()
|
||||||
|
{
|
||||||
|
if ( !self::requireAuth() )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ( !\S::csrf_verify() )
|
||||||
|
{
|
||||||
|
\S::alert( 'Nieprawidlowy token bezpieczenstwa. Odswiez strone i sproboj ponownie.' );
|
||||||
|
header( 'Location: /finances/main_view/' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalKey = trim( (string)\S::get( 'external_key' ) );
|
||||||
|
$externalName = trim( (string)\S::get( 'external_name' ) );
|
||||||
|
$financeCategoryId = (int)\S::get( 'finance_category_id' );
|
||||||
|
$repo = self::repo();
|
||||||
|
|
||||||
|
if ( $externalKey === '' || $externalName === '' || $financeCategoryId <= 0 || !$repo -> categoryExists( $financeCategoryId ) )
|
||||||
|
{
|
||||||
|
\S::alert( 'Nie udalo sie zapisac mapowania pozycji. Uzupelnij wszystkie pola.' );
|
||||||
|
header( 'Location: /finances/main_view/' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$importRepo = self::importRepo();
|
||||||
|
$importRepo -> ensureTables();
|
||||||
|
$importRepo -> saveItemMapping( $externalKey, $externalName, $financeCategoryId );
|
||||||
|
|
||||||
|
\S::alert( 'Mapowanie pozycji zostalo zapisane.' );
|
||||||
|
header( 'Location: /finances/main_view/' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
autoload/Domain/Finances/FakturowniaApiClient.php
Normal file
162
autoload/Domain/Finances/FakturowniaApiClient.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
namespace Domain\Finances;
|
||||||
|
|
||||||
|
class FakturowniaApiClient
|
||||||
|
{
|
||||||
|
private $baseUrl;
|
||||||
|
private $apiToken;
|
||||||
|
private $pageLimit;
|
||||||
|
private $timeout;
|
||||||
|
|
||||||
|
public function __construct( $baseUrl, $apiToken, $pageLimit = 100, $timeout = 20 )
|
||||||
|
{
|
||||||
|
$this -> baseUrl = rtrim( (string)$baseUrl, '/' );
|
||||||
|
$this -> apiToken = (string)$apiToken;
|
||||||
|
$this -> pageLimit = max( 1, (int)$pageLimit );
|
||||||
|
$this -> timeout = max( 5, (int)$timeout );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchSalesDocuments( $startDate, $page = 1 )
|
||||||
|
{
|
||||||
|
$query = [
|
||||||
|
'page' => (int)$page,
|
||||||
|
'per_page' => $this -> pageLimit
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $this -> canUseCurrentMonthPeriod( $startDate ) )
|
||||||
|
$query['period'] = 'this_month';
|
||||||
|
|
||||||
|
return $this -> requestList( '/invoices.json', $query );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchCostDocuments( $startDate, $page = 1 )
|
||||||
|
{
|
||||||
|
$queries = [
|
||||||
|
[
|
||||||
|
'page' => (int)$page,
|
||||||
|
'per_page' => $this -> pageLimit,
|
||||||
|
'income' => 'no'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $this -> canUseCurrentMonthPeriod( $startDate ) )
|
||||||
|
$queries[0]['period'] = 'this_month';
|
||||||
|
|
||||||
|
$paths = [ '/costs.json', '/expenses.json', '/invoices.json' ];
|
||||||
|
|
||||||
|
foreach ( $paths as $path )
|
||||||
|
{
|
||||||
|
foreach ( $queries as $query )
|
||||||
|
{
|
||||||
|
$response = $this -> requestList( $path, $query, true );
|
||||||
|
if ( $response['ok'] )
|
||||||
|
return $response['data'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException( 'Nie udalo sie pobrac faktur kosztowych z API Fakturowni.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchInvoiceDetails( $invoiceId )
|
||||||
|
{
|
||||||
|
$invoiceId = (int)$invoiceId;
|
||||||
|
if ( $invoiceId <= 0 )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
$result = $this -> request( '/invoices/' . $invoiceId . '.json', [] );
|
||||||
|
if ( $result['http_code'] >= 400 )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
$data = json_decode( $result['body'], true );
|
||||||
|
return is_array( $data ) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestList( $path, $query, $softFail = false )
|
||||||
|
{
|
||||||
|
$result = $this -> request( $path, $query );
|
||||||
|
|
||||||
|
if ( $result['http_code'] >= 400 )
|
||||||
|
{
|
||||||
|
if ( $softFail )
|
||||||
|
return [ 'ok' => false, 'data' => [] ];
|
||||||
|
|
||||||
|
throw new \RuntimeException( 'Blad API Fakturowni: HTTP ' . $result['http_code'] . ' dla ' . $path );
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( $result['body'], true );
|
||||||
|
if ( !is_array( $data ) )
|
||||||
|
{
|
||||||
|
if ( $softFail )
|
||||||
|
return [ 'ok' => false, 'data' => [] ];
|
||||||
|
|
||||||
|
throw new \RuntimeException( 'API Fakturowni zwrocilo niepoprawny JSON.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $this -> extractList( $data );
|
||||||
|
return $softFail ? [ 'ok' => true, 'data' => $list ] : $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request( $path, $query )
|
||||||
|
{
|
||||||
|
$query['api_token'] = $this -> apiToken;
|
||||||
|
$url = $this -> baseUrl . $path . '?' . http_build_query( $query );
|
||||||
|
|
||||||
|
$ch = curl_init( $url );
|
||||||
|
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||||
|
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, $this -> timeout );
|
||||||
|
curl_setopt( $ch, CURLOPT_TIMEOUT, $this -> timeout );
|
||||||
|
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Accept: application/json'
|
||||||
|
] );
|
||||||
|
|
||||||
|
$body = curl_exec( $ch );
|
||||||
|
$httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
$error = curl_error( $ch );
|
||||||
|
curl_close( $ch );
|
||||||
|
|
||||||
|
if ( $body === false )
|
||||||
|
throw new \RuntimeException( 'Blad polaczenia z API Fakturowni: ' . $error );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'body' => $body
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractList( $data )
|
||||||
|
{
|
||||||
|
if ( $this -> isList( $data ) )
|
||||||
|
return $data;
|
||||||
|
|
||||||
|
$keys = [ 'invoices', 'costs', 'expenses', 'data' ];
|
||||||
|
foreach ( $keys as $key )
|
||||||
|
{
|
||||||
|
if ( isset( $data[ $key ] ) && is_array( $data[ $key ] ) )
|
||||||
|
{
|
||||||
|
if ( $this -> isList( $data[ $key ] ) )
|
||||||
|
return $data[ $key ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isList( $value )
|
||||||
|
{
|
||||||
|
if ( !is_array( $value ) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ( $value === [] )
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return array_keys( $value ) === range( 0, count( $value ) - 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseCurrentMonthPeriod( $startDate )
|
||||||
|
{
|
||||||
|
if ( !is_string( $startDate ) || !preg_match( '/^\d{4}-\d{2}-\d{2}$/', $startDate ) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return $startDate === date( 'Y-m-01' );
|
||||||
|
}
|
||||||
|
}
|
||||||
318
autoload/Domain/Finances/FakturowniaImportRepository.php
Normal file
318
autoload/Domain/Finances/FakturowniaImportRepository.php
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<?php
|
||||||
|
namespace Domain\Finances;
|
||||||
|
|
||||||
|
class FakturowniaImportRepository
|
||||||
|
{
|
||||||
|
private $mdb;
|
||||||
|
private $tablesReady = false;
|
||||||
|
|
||||||
|
public function __construct( $mdb = null )
|
||||||
|
{
|
||||||
|
if ( $mdb )
|
||||||
|
$this -> mdb = $mdb;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
$this -> mdb = $mdb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureTables()
|
||||||
|
{
|
||||||
|
if ( $this -> tablesReady )
|
||||||
|
return;
|
||||||
|
|
||||||
|
$this -> mdb -> query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `fakturownia_client_mappings` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`external_client_key` VARCHAR(191) NOT NULL,
|
||||||
|
`external_name` VARCHAR(255) NOT NULL,
|
||||||
|
`crm_client_id` INT UNSIGNED NOT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL,
|
||||||
|
`updated_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_external_client_key` (`external_client_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this -> mdb -> query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `fakturownia_item_mappings` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`external_item_key` VARCHAR(191) NOT NULL,
|
||||||
|
`external_name` VARCHAR(255) NOT NULL,
|
||||||
|
`finance_category_id` INT UNSIGNED NOT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL,
|
||||||
|
`updated_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_external_item_key` (`external_item_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this -> mdb -> query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `fakturownia_imported_documents` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`external_document_key` VARCHAR(191) NOT NULL,
|
||||||
|
`document_type` VARCHAR(32) NOT NULL,
|
||||||
|
`external_id` VARCHAR(64) NOT NULL,
|
||||||
|
`source_date` DATE NULL,
|
||||||
|
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`finance_operation_ids` TEXT NULL,
|
||||||
|
`meta_json` LONGTEXT NULL,
|
||||||
|
`imported_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_external_document_key` (`external_document_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this -> mdb -> query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `fakturownia_unmapped_queue` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`queue_type` VARCHAR(32) NOT NULL,
|
||||||
|
`external_key` VARCHAR(191) NOT NULL,
|
||||||
|
`external_name` VARCHAR(255) NOT NULL,
|
||||||
|
`payload_json` LONGTEXT NULL,
|
||||||
|
`hits` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
`resolved` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`first_seen_at` DATETIME NOT NULL,
|
||||||
|
`last_seen_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_type_key` (`queue_type`, `external_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this -> mdb -> query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `fakturownia_import_state` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`state_key` VARCHAR(191) NOT NULL,
|
||||||
|
`state_value` LONGTEXT NULL,
|
||||||
|
`updated_at` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_state_key` (`state_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this -> tablesReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientMapping( $externalClientKey )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
return $this -> mdb -> get( 'fakturownia_client_mappings', '*', [
|
||||||
|
'external_client_key' => $externalClientKey
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveClientMapping( $externalClientKey, $externalName, $crmClientId )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
|
||||||
|
$current = $this -> getClientMapping( $externalClientKey );
|
||||||
|
$now = date( 'Y-m-d H:i:s' );
|
||||||
|
|
||||||
|
if ( $current )
|
||||||
|
{
|
||||||
|
$this -> mdb -> update( 'fakturownia_client_mappings', [
|
||||||
|
'external_name' => $externalName,
|
||||||
|
'crm_client_id' => (int)$crmClientId,
|
||||||
|
'updated_at' => $now
|
||||||
|
], [ 'id' => (int)$current['id'] ] );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$this -> mdb -> insert( 'fakturownia_client_mappings', [
|
||||||
|
'external_client_key' => $externalClientKey,
|
||||||
|
'external_name' => $externalName,
|
||||||
|
'crm_client_id' => (int)$crmClientId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> resolveQueueItem( 'client', $externalClientKey );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getItemMapping( $externalItemKey )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
return $this -> mdb -> get( 'fakturownia_item_mappings', '*', [
|
||||||
|
'external_item_key' => $externalItemKey
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveItemMapping( $externalItemKey, $externalName, $financeCategoryId )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
|
||||||
|
$current = $this -> getItemMapping( $externalItemKey );
|
||||||
|
$now = date( 'Y-m-d H:i:s' );
|
||||||
|
|
||||||
|
if ( $current )
|
||||||
|
{
|
||||||
|
$this -> mdb -> update( 'fakturownia_item_mappings', [
|
||||||
|
'external_name' => $externalName,
|
||||||
|
'finance_category_id' => (int)$financeCategoryId,
|
||||||
|
'updated_at' => $now
|
||||||
|
], [ 'id' => (int)$current['id'] ] );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$this -> mdb -> insert( 'fakturownia_item_mappings', [
|
||||||
|
'external_item_key' => $externalItemKey,
|
||||||
|
'external_name' => $externalName,
|
||||||
|
'finance_category_id' => (int)$financeCategoryId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> resolveQueueItem( 'item', $externalItemKey );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDocumentImported( $externalDocumentKey )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
return (bool)$this -> mdb -> has( 'fakturownia_imported_documents', [
|
||||||
|
'external_document_key' => $externalDocumentKey
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markDocumentImported( $externalDocumentKey, $documentType, $externalId, $sourceDate, $amount, $operationIds, $meta )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
|
||||||
|
$now = date( 'Y-m-d H:i:s' );
|
||||||
|
$data = [
|
||||||
|
'external_document_key' => $externalDocumentKey,
|
||||||
|
'document_type' => $documentType,
|
||||||
|
'external_id' => (string)$externalId,
|
||||||
|
'source_date' => $sourceDate,
|
||||||
|
'amount' => (float)$amount,
|
||||||
|
'finance_operation_ids' => implode( ',', $operationIds ),
|
||||||
|
'meta_json' => json_encode( $meta, JSON_UNESCAPED_UNICODE ),
|
||||||
|
'imported_at' => $now
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this -> mdb -> get( 'fakturownia_imported_documents', 'id', [
|
||||||
|
'external_document_key' => $externalDocumentKey
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( $existing )
|
||||||
|
{
|
||||||
|
$this -> mdb -> update( 'fakturownia_imported_documents', $data, [
|
||||||
|
'id' => (int)$existing
|
||||||
|
] );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> mdb -> insert( 'fakturownia_imported_documents', $data );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queueUnmapped( $queueType, $externalKey, $externalName, $payload )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
|
||||||
|
$existing = $this -> mdb -> get( 'fakturownia_unmapped_queue', '*', [
|
||||||
|
'AND' => [
|
||||||
|
'queue_type' => $queueType,
|
||||||
|
'external_key' => $externalKey
|
||||||
|
]
|
||||||
|
] );
|
||||||
|
|
||||||
|
$now = date( 'Y-m-d H:i:s' );
|
||||||
|
$payloadJson = json_encode( $payload, JSON_UNESCAPED_UNICODE );
|
||||||
|
|
||||||
|
if ( $existing )
|
||||||
|
{
|
||||||
|
$this -> mdb -> update( 'fakturownia_unmapped_queue', [
|
||||||
|
'external_name' => $externalName,
|
||||||
|
'payload_json' => $payloadJson,
|
||||||
|
'hits[+]' => 1,
|
||||||
|
'resolved' => 0,
|
||||||
|
'last_seen_at' => $now
|
||||||
|
], [ 'id' => (int)$existing['id'] ] );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> mdb -> insert( 'fakturownia_unmapped_queue', [
|
||||||
|
'queue_type' => $queueType,
|
||||||
|
'external_key' => $externalKey,
|
||||||
|
'external_name' => $externalName,
|
||||||
|
'payload_json' => $payloadJson,
|
||||||
|
'hits' => 1,
|
||||||
|
'resolved' => 0,
|
||||||
|
'first_seen_at' => $now,
|
||||||
|
'last_seen_at' => $now
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingClientMappings()
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
return $this -> mdb -> select( 'fakturownia_unmapped_queue', '*', [
|
||||||
|
'AND' => [
|
||||||
|
'queue_type' => 'client',
|
||||||
|
'resolved' => 0
|
||||||
|
],
|
||||||
|
'ORDER' => [ 'last_seen_at' => 'DESC' ]
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingItemMappings()
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
return $this -> mdb -> select( 'fakturownia_unmapped_queue', '*', [
|
||||||
|
'AND' => [
|
||||||
|
'queue_type' => 'item',
|
||||||
|
'resolved' => 0,
|
||||||
|
'external_key[!]' => 'name:faktura bez pozycji'
|
||||||
|
],
|
||||||
|
'ORDER' => [ 'last_seen_at' => 'DESC' ]
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveState( $stateKey, $stateValue )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
|
||||||
|
$existing = $this -> mdb -> get( 'fakturownia_import_state', '*', [
|
||||||
|
'state_key' => $stateKey
|
||||||
|
] );
|
||||||
|
$now = date( 'Y-m-d H:i:s' );
|
||||||
|
|
||||||
|
if ( $existing )
|
||||||
|
{
|
||||||
|
$this -> mdb -> update( 'fakturownia_import_state', [
|
||||||
|
'state_value' => $stateValue,
|
||||||
|
'updated_at' => $now
|
||||||
|
], [ 'id' => (int)$existing['id'] ] );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> mdb -> insert( 'fakturownia_import_state', [
|
||||||
|
'state_key' => $stateKey,
|
||||||
|
'state_value' => $stateValue,
|
||||||
|
'updated_at' => $now
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getState( $stateKey )
|
||||||
|
{
|
||||||
|
$this -> ensureTables();
|
||||||
|
return $this -> mdb -> get( 'fakturownia_import_state', 'state_value', [
|
||||||
|
'state_key' => $stateKey
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveQueueItem( $queueType, $externalKey )
|
||||||
|
{
|
||||||
|
$this -> mdb -> update( 'fakturownia_unmapped_queue', [
|
||||||
|
'resolved' => 1,
|
||||||
|
'last_seen_at' => date( 'Y-m-d H:i:s' )
|
||||||
|
], [
|
||||||
|
'AND' => [
|
||||||
|
'queue_type' => $queueType,
|
||||||
|
'external_key' => $externalKey
|
||||||
|
]
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
}
|
||||||
461
autoload/Domain/Finances/FakturowniaInvoiceImporter.php
Normal file
461
autoload/Domain/Finances/FakturowniaInvoiceImporter.php
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
<?php
|
||||||
|
namespace Domain\Finances;
|
||||||
|
|
||||||
|
class FakturowniaInvoiceImporter
|
||||||
|
{
|
||||||
|
private $mdb;
|
||||||
|
private $repo;
|
||||||
|
private $apiClient;
|
||||||
|
private $startDate;
|
||||||
|
private $pageLimit = 100;
|
||||||
|
|
||||||
|
public function __construct( $mdb = null )
|
||||||
|
{
|
||||||
|
if ( $mdb )
|
||||||
|
$this -> mdb = $mdb;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
$this -> mdb = $mdb;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> repo = new FakturowniaImportRepository( $this -> mdb );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function import()
|
||||||
|
{
|
||||||
|
$this -> repo -> ensureTables();
|
||||||
|
|
||||||
|
$config = $this -> resolveConfig();
|
||||||
|
if ( !$config['ok'] )
|
||||||
|
return $config;
|
||||||
|
|
||||||
|
$this -> startDate = $config['start_date'];
|
||||||
|
$this -> apiClient = new FakturowniaApiClient(
|
||||||
|
$config['api_url'],
|
||||||
|
$config['token'],
|
||||||
|
$config['page_limit']
|
||||||
|
);
|
||||||
|
$this -> pageLimit = (int)$config['page_limit'];
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'imported' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'unmapped' => 0,
|
||||||
|
'errors' => 0
|
||||||
|
];
|
||||||
|
|
||||||
|
$summary = $this -> processDocumentType( 'income', $summary );
|
||||||
|
$summary = $this -> processDocumentType( 'cost', $summary );
|
||||||
|
|
||||||
|
$lastSummary = json_encode( [
|
||||||
|
'at' => date( 'Y-m-d H:i:s' ),
|
||||||
|
'summary' => $summary
|
||||||
|
], JSON_UNESCAPED_UNICODE );
|
||||||
|
$this -> repo -> saveState( 'last_import_summary', $lastSummary );
|
||||||
|
|
||||||
|
if ( $summary['imported'] === 0 && $summary['unmapped'] === 0 && $summary['errors'] === 0 )
|
||||||
|
return [ 'status' => 'empty', 'msg' => 'Import Fakturownia: brak nowych dokumentow.' ];
|
||||||
|
|
||||||
|
if ( $summary['errors'] > 0 )
|
||||||
|
return [ 'status' => 'error', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ];
|
||||||
|
|
||||||
|
return [ 'status' => 'ok', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processDocumentType( $documentType, $summary )
|
||||||
|
{
|
||||||
|
$page = 1;
|
||||||
|
|
||||||
|
while ( true )
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if ( $documentType === 'income' )
|
||||||
|
$documents = $this -> apiClient -> fetchSalesDocuments( $this -> startDate, $page );
|
||||||
|
else
|
||||||
|
$documents = $this -> apiClient -> fetchCostDocuments( $this -> startDate, $page );
|
||||||
|
}
|
||||||
|
catch ( \Throwable $e )
|
||||||
|
{
|
||||||
|
$summary['errors']++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !is_array( $documents ) || empty( $documents ) )
|
||||||
|
break;
|
||||||
|
|
||||||
|
$hasRelevantDateInPage = false;
|
||||||
|
|
||||||
|
foreach ( $documents as $document )
|
||||||
|
{
|
||||||
|
if ( $this -> isDateRelevantForImport( $document ) )
|
||||||
|
$hasRelevantDateInPage = true;
|
||||||
|
|
||||||
|
$result = $this -> processSingleDocument( $document, $documentType );
|
||||||
|
$summary[ $result ]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( count( $documents ) < $this -> pageLimit )
|
||||||
|
break;
|
||||||
|
|
||||||
|
// API zwraca dokumenty malejaco po czasie. Gdy cala strona jest starsza niz startDate,
|
||||||
|
// kolejne strony tez beda starsze i nie ma sensu pobierac dalej.
|
||||||
|
if ( !$hasRelevantDateInPage )
|
||||||
|
break;
|
||||||
|
|
||||||
|
$page++;
|
||||||
|
if ( $page > 100 )
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processSingleDocument( $rawDocument, $documentType )
|
||||||
|
{
|
||||||
|
if ( !$this -> matchesDocumentType( $rawDocument, $documentType ) )
|
||||||
|
return 'skipped';
|
||||||
|
|
||||||
|
$document = $this -> normalizeDocument( $rawDocument, $documentType );
|
||||||
|
if ( !$document )
|
||||||
|
return 'skipped';
|
||||||
|
|
||||||
|
if ( $this -> repo -> isDocumentImported( $document['document_key'] ) )
|
||||||
|
return 'skipped';
|
||||||
|
|
||||||
|
if ( strtotime( $document['date'] ) < strtotime( $this -> startDate ) )
|
||||||
|
return 'skipped';
|
||||||
|
|
||||||
|
$clientMap = $this -> repo -> getClientMapping( $document['client_key'] );
|
||||||
|
if ( !$clientMap )
|
||||||
|
{
|
||||||
|
$this -> repo -> queueUnmapped( 'client', $document['client_key'], $document['client_name'], [
|
||||||
|
'document_id' => $document['external_id'],
|
||||||
|
'document_number' => $document['number'],
|
||||||
|
'document_type' => $documentType,
|
||||||
|
'tax_no' => $document['client_tax_no']
|
||||||
|
] );
|
||||||
|
return 'unmapped';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedPositions = [];
|
||||||
|
foreach ( $document['positions'] as $position )
|
||||||
|
{
|
||||||
|
$itemMap = $this -> repo -> getItemMapping( $position['item_key'] );
|
||||||
|
if ( !$itemMap )
|
||||||
|
{
|
||||||
|
$this -> repo -> queueUnmapped( 'item', $position['item_key'], $position['name'], [
|
||||||
|
'document_id' => $document['external_id'],
|
||||||
|
'document_number' => $document['number'],
|
||||||
|
'document_type' => $documentType
|
||||||
|
] );
|
||||||
|
return 'unmapped';
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $this -> normalizeAmount( $position['amount'], $documentType );
|
||||||
|
if ( $amount == 0.0 )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$resolvedPositions[] = [
|
||||||
|
'category_id' => (int)$itemMap['finance_category_id'],
|
||||||
|
'amount' => $amount,
|
||||||
|
'description' => $this -> buildDescription( $document, $position )
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $resolvedPositions ) )
|
||||||
|
return 'skipped';
|
||||||
|
|
||||||
|
$operationIds = [];
|
||||||
|
|
||||||
|
// Cala faktura importuje sie atomowo: albo wszystkie pozycje, albo nic.
|
||||||
|
$this -> mdb -> pdo -> beginTransaction();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach ( $resolvedPositions as $resolvedPosition )
|
||||||
|
{
|
||||||
|
$this -> mdb -> insert( 'finance_operations', [
|
||||||
|
'date' => $document['date'],
|
||||||
|
'category_id' => $resolvedPosition['category_id'],
|
||||||
|
'amount' => $resolvedPosition['amount'],
|
||||||
|
'description' => $resolvedPosition['description'],
|
||||||
|
'client_id' => (int)$clientMap['crm_client_id']
|
||||||
|
] );
|
||||||
|
|
||||||
|
$operationIds[] = (int)$this -> mdb -> id();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> repo -> markDocumentImported(
|
||||||
|
$document['document_key'],
|
||||||
|
$documentType,
|
||||||
|
$document['external_id'],
|
||||||
|
$document['date'],
|
||||||
|
$document['total_amount'],
|
||||||
|
$operationIds,
|
||||||
|
[
|
||||||
|
'number' => $document['number'],
|
||||||
|
'client_name' => $document['client_name']
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this -> mdb -> pdo -> commit();
|
||||||
|
}
|
||||||
|
catch ( \Throwable $e )
|
||||||
|
{
|
||||||
|
if ( $this -> mdb -> pdo ->inTransaction() )
|
||||||
|
$this -> mdb -> pdo -> rollBack();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'imported';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesDocumentType( $rawDocument, $documentType )
|
||||||
|
{
|
||||||
|
if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) )
|
||||||
|
$rawDocument = $rawDocument['invoice'];
|
||||||
|
|
||||||
|
if ( !is_array( $rawDocument ) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ( !array_key_exists( 'income', $rawDocument ) )
|
||||||
|
return true;
|
||||||
|
|
||||||
|
$incomeFlag = $rawDocument['income'];
|
||||||
|
$isIncome = !in_array( $incomeFlag, [ false, 0, '0', 'false', 'FALSE' ], true );
|
||||||
|
|
||||||
|
if ( $documentType === 'income' )
|
||||||
|
return $isIncome;
|
||||||
|
|
||||||
|
return !$isIncome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDateRelevantForImport( $rawDocument )
|
||||||
|
{
|
||||||
|
if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) )
|
||||||
|
$rawDocument = $rawDocument['invoice'];
|
||||||
|
|
||||||
|
if ( !is_array( $rawDocument ) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
$date = (string)( $rawDocument['issue_date'] ?? $rawDocument['sell_date'] ?? $rawDocument['created_at'] ?? '' );
|
||||||
|
$date = substr( $date, 0, 10 );
|
||||||
|
if ( !$date )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return strtotime( $date ) >= strtotime( $this -> startDate );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDocument( $rawDocument, $documentType )
|
||||||
|
{
|
||||||
|
if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) )
|
||||||
|
$rawDocument = $rawDocument['invoice'];
|
||||||
|
|
||||||
|
if ( !is_array( $rawDocument ) )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
$externalId = isset( $rawDocument['id'] ) ? (string)$rawDocument['id'] : '';
|
||||||
|
if ( $externalId === '' )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
$number = (string)( $rawDocument['number'] ?? $rawDocument['full_number'] ?? '' );
|
||||||
|
$date = (string)( $rawDocument['issue_date'] ?? $rawDocument['sell_date'] ?? $rawDocument['created_at'] ?? date( 'Y-m-d' ) );
|
||||||
|
$date = substr( $date, 0, 10 );
|
||||||
|
|
||||||
|
if ( $documentType === 'income' )
|
||||||
|
{
|
||||||
|
$clientName = (string)( $rawDocument['buyer_name'] ?? $rawDocument['client_name'] ?? 'Nieznany klient' );
|
||||||
|
$clientTaxNo = (string)( $rawDocument['buyer_tax_no'] ?? $rawDocument['tax_no'] ?? '' );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$clientName = (string)( $rawDocument['seller_name'] ?? $rawDocument['client_name'] ?? 'Nieznany kontrahent' );
|
||||||
|
$clientTaxNo = (string)( $rawDocument['seller_tax_no'] ?? $rawDocument['tax_no'] ?? '' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$positions = [];
|
||||||
|
$totalAmount = 0.0;
|
||||||
|
|
||||||
|
$rawPositions = $this -> resolvePositions( $rawDocument );
|
||||||
|
foreach ( $rawPositions as $rawPosition )
|
||||||
|
{
|
||||||
|
if ( !is_array( $rawPosition ) )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$name = trim( (string)( $rawPosition['name'] ?? '' ) );
|
||||||
|
if ( $name === '' )
|
||||||
|
$name = 'Pozycja';
|
||||||
|
|
||||||
|
$amount = $this -> resolvePositionAmount( $rawPosition );
|
||||||
|
$totalAmount += $amount;
|
||||||
|
|
||||||
|
$positions[] = [
|
||||||
|
'item_key' => $this -> buildItemKey( $rawPosition, $name ),
|
||||||
|
'name' => $name,
|
||||||
|
'amount' => $amount
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $positions ) )
|
||||||
|
{
|
||||||
|
$fallback = (float)( $rawDocument['price_gross'] ?? $rawDocument['price'] ?? 0 );
|
||||||
|
$fallbackName = trim( (string)( $rawDocument['product_cache'] ?? '' ) );
|
||||||
|
if ( $fallbackName === '' )
|
||||||
|
$fallbackName = 'Faktura bez pozycji';
|
||||||
|
|
||||||
|
if ( $fallback != 0.0 )
|
||||||
|
{
|
||||||
|
$positions[] = [
|
||||||
|
'item_key' => $this -> buildItemKey( [], $fallbackName ),
|
||||||
|
'name' => $fallbackName,
|
||||||
|
'amount' => $fallback
|
||||||
|
];
|
||||||
|
$totalAmount = $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $positions ) )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'external_id' => $externalId,
|
||||||
|
'document_key' => $documentType . ':' . $externalId,
|
||||||
|
'number' => $number,
|
||||||
|
'date' => $date,
|
||||||
|
'client_name' => $clientName,
|
||||||
|
'client_tax_no' => $clientTaxNo,
|
||||||
|
'client_key' => $this -> buildClientKey( $rawDocument, $clientName, $clientTaxNo ),
|
||||||
|
'positions' => $positions,
|
||||||
|
'total_amount' => $this -> normalizeAmount( $totalAmount, $documentType )
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePositions( $rawDocument )
|
||||||
|
{
|
||||||
|
if ( isset( $rawDocument['positions'] ) && is_array( $rawDocument['positions'] ) && !empty( $rawDocument['positions'] ) )
|
||||||
|
return $rawDocument['positions'];
|
||||||
|
|
||||||
|
$invoiceId = isset( $rawDocument['id'] ) ? (int)$rawDocument['id'] : 0;
|
||||||
|
if ( $invoiceId <= 0 )
|
||||||
|
return [];
|
||||||
|
|
||||||
|
$details = $this -> apiClient -> fetchInvoiceDetails( $invoiceId );
|
||||||
|
if ( is_array( $details ) && isset( $details['positions'] ) && is_array( $details['positions'] ) )
|
||||||
|
return $details['positions'];
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePositionAmount( $position )
|
||||||
|
{
|
||||||
|
$quantity = (float)( $position['quantity'] ?? 1 );
|
||||||
|
if ( $quantity <= 0 )
|
||||||
|
$quantity = 1;
|
||||||
|
|
||||||
|
$totalNet = (float)( $position['total_price_net'] ?? 0 );
|
||||||
|
if ( $totalNet != 0.0 )
|
||||||
|
return $totalNet;
|
||||||
|
|
||||||
|
$unitNet = (float)( $position['price'] ?? $position['price_net'] ?? 0 );
|
||||||
|
if ( $unitNet != 0.0 )
|
||||||
|
return $unitNet * $quantity;
|
||||||
|
|
||||||
|
// Fallback dla pozycji, ktore nie maja netto w payloadzie.
|
||||||
|
$totalGross = (float)( $position['total_price_gross'] ?? 0 );
|
||||||
|
if ( $totalGross != 0.0 )
|
||||||
|
return $totalGross;
|
||||||
|
|
||||||
|
$unitGross = (float)( $position['price_gross'] ?? 0 );
|
||||||
|
if ( $unitGross != 0.0 )
|
||||||
|
return $unitGross * $quantity;
|
||||||
|
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildClientKey( $rawDocument, $clientName, $clientTaxNo )
|
||||||
|
{
|
||||||
|
if ( isset( $rawDocument['client_id'] ) && (string)$rawDocument['client_id'] !== '' )
|
||||||
|
return 'id:' . (string)$rawDocument['client_id'];
|
||||||
|
|
||||||
|
if ( $clientTaxNo !== '' )
|
||||||
|
return 'tax:' . preg_replace( '/\s+/', '', $clientTaxNo );
|
||||||
|
|
||||||
|
return 'name:' . $this -> normalizeKeyValue( $clientName );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildItemKey( $rawPosition, $name )
|
||||||
|
{
|
||||||
|
if ( isset( $rawPosition['product_id'] ) && (string)$rawPosition['product_id'] !== '' )
|
||||||
|
return 'product:' . (string)$rawPosition['product_id'];
|
||||||
|
|
||||||
|
return 'name:' . $this -> normalizeKeyValue( $name );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeKeyValue( $value )
|
||||||
|
{
|
||||||
|
$value = trim( (string)$value );
|
||||||
|
if ( function_exists( 'mb_strtolower' ) )
|
||||||
|
return mb_strtolower( $value, 'UTF-8' );
|
||||||
|
|
||||||
|
return strtolower( $value );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAmount( $amount, $documentType )
|
||||||
|
{
|
||||||
|
$amount = (float)$amount;
|
||||||
|
if ( $documentType === 'cost' )
|
||||||
|
return abs( $amount ) * -1;
|
||||||
|
|
||||||
|
return abs( $amount );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDescription( $document, $position )
|
||||||
|
{
|
||||||
|
$parts = [
|
||||||
|
'Fakturownia'
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $document['number'] !== '' )
|
||||||
|
$parts[] = 'FV: ' . $document['number'];
|
||||||
|
|
||||||
|
$parts[] = $position['name'];
|
||||||
|
$parts[] = $document['client_name'];
|
||||||
|
|
||||||
|
return implode( ' | ', $parts );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveConfig()
|
||||||
|
{
|
||||||
|
$domain = trim( (string)\Env::get( 'FAKTUROWNIA_API_DOMAIN', '' ) );
|
||||||
|
$token = trim( (string)\Env::get( 'FAKTUROWNIA_API_TOKEN', '' ) );
|
||||||
|
$startDate = trim( (string)\Env::get( 'FAKTUROWNIA_START_DATE', '' ) );
|
||||||
|
$pageLimit = (int)\Env::get( 'FAKTUROWNIA_PAGE_LIMIT', 100 );
|
||||||
|
|
||||||
|
if ( $domain === '' || $token === '' || $startDate === '' )
|
||||||
|
return [ 'status' => 'error', 'ok' => false, 'msg' => 'Import Fakturownia: brak konfiguracji w .env.' ];
|
||||||
|
|
||||||
|
if ( !preg_match( '/^\d{4}-\d{2}-\d{2}$/', $startDate ) )
|
||||||
|
return [ 'status' => 'error', 'ok' => false, 'msg' => 'Import Fakturownia: nieprawidlowa data FAKTUROWNIA_START_DATE.' ];
|
||||||
|
|
||||||
|
if ( strpos( $domain, 'http://' ) !== 0 && strpos( $domain, 'https://' ) !== 0 )
|
||||||
|
$domain = 'https://' . $domain;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'api_url' => rtrim( $domain, '/' ),
|
||||||
|
'token' => $token,
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'page_limit' => $pageLimit > 0 ? $pageLimit : 100
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMessage( $summary )
|
||||||
|
{
|
||||||
|
return 'Import Fakturownia: zaimportowano ' . (int)$summary['imported']
|
||||||
|
. ', pominieto ' . (int)$summary['skipped']
|
||||||
|
. ', brak mapowan ' . (int)$summary['unmapped']
|
||||||
|
. ', bledy ' . (int)$summary['errors'] . '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,23 @@ class FinanceRepository
|
|||||||
return $this -> mdb -> select( 'crm_client', [ 'id', 'firm' ], [ 'ORDER' => [ 'firm' => 'ASC' ] ] );
|
return $this -> mdb -> select( 'crm_client', [ 'id', 'firm' ], [ 'ORDER' => [ 'firm' => 'ASC' ] ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function categoriesFlatList()
|
||||||
|
{
|
||||||
|
return $this -> mdb -> select( 'finance_categories', [ 'id', 'name', 'group_id', 'parent_id' ], [
|
||||||
|
'ORDER' => [ 'name' => 'ASC' ]
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clientExists( $clientId )
|
||||||
|
{
|
||||||
|
return (bool)$this -> mdb -> has( 'crm_client', [ 'id' => (int)$clientId ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categoryExists( $categoryId )
|
||||||
|
{
|
||||||
|
return (bool)$this -> mdb -> has( 'finance_categories', [ 'id' => (int)$categoryId ] );
|
||||||
|
}
|
||||||
|
|
||||||
public function clientsWithRevenue( $date_from, $date_to, $group_id )
|
public function clientsWithRevenue( $date_from, $date_to, $group_id )
|
||||||
{
|
{
|
||||||
return $this -> mdb -> query(
|
return $this -> mdb -> query(
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
class Cron
|
class Cron
|
||||||
{
|
{
|
||||||
|
public static function import_finances_from_fakturownia()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$importer = new \Domain\Finances\FakturowniaInvoiceImporter();
|
||||||
|
return $importer -> import();
|
||||||
|
}
|
||||||
|
catch ( \Throwable $e )
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'msg' => 'Import Fakturownia: ' . $e -> getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function recursive_tasks()
|
public static function recursive_tasks()
|
||||||
{
|
{
|
||||||
global $mdb;
|
global $mdb;
|
||||||
|
|||||||
64
autoload/class.Env.php
Normal file
64
autoload/class.Env.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Env
|
||||||
|
{
|
||||||
|
private static $loaded = false;
|
||||||
|
private static $values = [];
|
||||||
|
|
||||||
|
public static function load( $path = '.env' )
|
||||||
|
{
|
||||||
|
if ( self::$loaded )
|
||||||
|
return;
|
||||||
|
|
||||||
|
self::$loaded = true;
|
||||||
|
|
||||||
|
if ( !is_string( $path ) || $path === '' || !file_exists( $path ) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
$lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
|
||||||
|
if ( !is_array( $lines ) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach ( $lines as $line )
|
||||||
|
{
|
||||||
|
$line = trim( $line );
|
||||||
|
if ( $line === '' || strpos( $line, '#' ) === 0 )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$parts = explode( '=', $line, 2 );
|
||||||
|
if ( count( $parts ) !== 2 )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$key = trim( $parts[0] );
|
||||||
|
$value = trim( $parts[1] );
|
||||||
|
if ( $key === '' )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if ( strlen( $value ) >= 2 )
|
||||||
|
{
|
||||||
|
$first = $value[0];
|
||||||
|
$last = $value[strlen( $value ) - 1];
|
||||||
|
if ( ( $first === '"' && $last === '"' ) || ( $first === "'" && $last === "'" ) )
|
||||||
|
$value = substr( $value, 1, -1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$values[ $key ] = $value;
|
||||||
|
$_ENV[ $key ] = $value;
|
||||||
|
putenv( $key . '=' . $value );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get( $key, $default = null )
|
||||||
|
{
|
||||||
|
self::load();
|
||||||
|
|
||||||
|
$value = getenv( $key );
|
||||||
|
if ( $value !== false )
|
||||||
|
return $value;
|
||||||
|
|
||||||
|
if ( isset( self::$values[ $key ] ) )
|
||||||
|
return self::$values[ $key ];
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
cron.php
29
cron.php
@@ -1,4 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
error_reporting( E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED );
|
error_reporting( E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED );
|
||||||
|
|
||||||
function __autoload_my_classes( $classname )
|
function __autoload_my_classes( $classname )
|
||||||
@@ -23,6 +23,7 @@ require_once 'libraries/grid/config.php';
|
|||||||
require_once 'libraries/rb.php';
|
require_once 'libraries/rb.php';
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
|
\Env::load();
|
||||||
|
|
||||||
if ( !isset( $_SESSION['check'] ) )
|
if ( !isset( $_SESSION['check'] ) )
|
||||||
{
|
{
|
||||||
@@ -53,6 +54,27 @@ $mdb = new medoo( [
|
|||||||
return R::getRedBean() -> dispense( $type );
|
return R::getRedBean() -> dispense( $type );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
/* diagnostyka: wymuszenie pojedynczego joba cron */
|
||||||
|
if ( \S::get( 'job' ) === 'fakturownia' )
|
||||||
|
{
|
||||||
|
$response = \Cron::import_finances_from_fakturownia();
|
||||||
|
echo json_encode( $response );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* import faktur z Fakturowni */
|
||||||
|
$response = \Cron::import_finances_from_fakturownia();
|
||||||
|
if ( $response['status'] == 'ok' )
|
||||||
|
{
|
||||||
|
echo json_encode( $response );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ( $response['status'] == 'error' )
|
||||||
|
{
|
||||||
|
echo json_encode( $response );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/* import zadan z email */
|
/* import zadan z email */
|
||||||
$response = \Cron::import_tasks_from_email();
|
$response = \Cron::import_tasks_from_email();
|
||||||
if ( $response['status'] == 'ok' )
|
if ( $response['status'] == 'ok' )
|
||||||
@@ -66,10 +88,9 @@ if ( $response['status'] == 'error' )
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if ( date( 'G' ) > 6 )
|
if ( date( 'G' ) > 6 )
|
||||||
{
|
{
|
||||||
/* wysyłanie przypomnnień do zadań */
|
/* wysylanie przypomnien do zadan */
|
||||||
$response = \Cron::tasks_emails();
|
$response = \Cron::tasks_emails();
|
||||||
if ( $response['status'] == 'ok' )
|
if ( $response['status'] == 'ok' )
|
||||||
{
|
{
|
||||||
@@ -78,7 +99,7 @@ if ( date( 'G' ) > 6 )
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* dodawanie zadań rekursywnych */
|
/* dodawanie zadan rekursywnych */
|
||||||
$response = \Cron::recursive_tasks();
|
$response = \Cron::recursive_tasks();
|
||||||
if ( $response['status'] == 'ok' )
|
if ( $response['status'] == 'ok' )
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1750,7 +1750,7 @@ $sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
|||||||
.finance-manager {
|
.finance-manager {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 200px 1fr 500px;
|
grid-template-columns: 250px 1fr 500px;
|
||||||
padding-top: 25px;
|
padding-top: 25px;
|
||||||
|
|
||||||
.manage-menu {
|
.manage-menu {
|
||||||
@@ -1999,36 +1999,248 @@ $sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
|||||||
.tasks_main_view {
|
.tasks_main_view {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
._left_column {
|
._left_column {
|
||||||
padding: 25px;
|
width: fit-content;
|
||||||
background: #fcfcfc;
|
min-width: 350px;
|
||||||
border-right: 1px solid #e8e8e8;
|
max-width: 520px;
|
||||||
width: 350px;
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-right: 0;
|
||||||
|
|
||||||
label {
|
.filters_panel {
|
||||||
font-weight: 400;
|
background: linear-gradient(180deg, #fdfefe 0%, #f4f7fb 100%);
|
||||||
font-size: 14px;
|
border: 1px solid #dbe2ea;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_panel_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_panel_title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: #1f3d72;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f3d72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active_filters_badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #425466;
|
||||||
|
background: #e9eff8;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f3d72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_controls {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #dbe2ea;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #425466;
|
||||||
}
|
}
|
||||||
|
|
||||||
select[name="filtr"] {
|
select[name="filtr"] {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
|
height: 40px;
|
||||||
|
border-color: #d1dae4;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #5f88e9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 136, 233, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
._buttons {
|
._buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
background: #3d495e;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2f394c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_section {
|
||||||
|
border: 1px solid #dbe2ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_section_header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f4f7fb;
|
||||||
|
border-bottom: 1px solid #e3eaf2;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #243447;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_search {
|
||||||
|
max-width: 170px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-color: #d2dbe6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_section_list {
|
||||||
|
max-height: 270px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_section._projects .filters_section_list {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters_item {
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f4f7fb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project_row,
|
||||||
|
._user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project_count {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4px;
|
||||||
|
min-width: 26px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1f3d72;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 19px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project_delete_inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #cc563d;
|
||||||
|
background: #cc563d;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin: 1px 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #b74831;
|
||||||
|
border-color: #b74831;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
._right_column {
|
._right_column {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: calc(100% - 350px);
|
max-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.tasks_main_view {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
._left_column,
|
||||||
|
._right_column {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-target {
|
||||||
|
.gantt-container.gantt-draggable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-container.gantt-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
templates/finances/fakturownia-import-panel.php
Normal file
148
templates/finances/fakturownia-import-panel.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
$esc = function( $value )
|
||||||
|
{
|
||||||
|
return htmlspecialchars( (string)$value, ENT_QUOTES, 'UTF-8' );
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fakturownia-panel {
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fakturownia-panel .panel-heading {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.fakturownia-panel .panel-title {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.fakturownia-panel .panel-body {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.fakturownia-panel .panel-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.fakturownia-panel .select2-container {
|
||||||
|
min-width: 320px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="panel panel-default fakturownia-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h4 class="panel-title">Import faktur z Fakturowni</h4>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<? if ( is_array( $this -> fakturownia_last_summary ) && isset( $this -> fakturownia_last_summary['summary'] ) ): ?>
|
||||||
|
<div class="alert alert-info mb15">
|
||||||
|
Ostatni import: <?= $esc( $this -> fakturownia_last_summary['at'] ?? '-' ); ?><br>
|
||||||
|
Zaimportowano: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['imported'] ?? 0 ); ?></strong>,
|
||||||
|
pominieto: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['skipped'] ?? 0 ); ?></strong>,
|
||||||
|
brak mapowan: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['unmapped'] ?? 0 ); ?></strong>,
|
||||||
|
bledy: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['errors'] ?? 0 ); ?></strong>
|
||||||
|
</div>
|
||||||
|
<? endif; ?>
|
||||||
|
|
||||||
|
<? if ( is_array( $this -> fakturownia_pending_clients ) && count( $this -> fakturownia_pending_clients ) ): ?>
|
||||||
|
<h5>Brakujace mapowania klientow</h5>
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Klient z Fakturowni</th>
|
||||||
|
<th>CRM klient</th>
|
||||||
|
<th style="width: 130px;">Akcja</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<? foreach ( $this -> fakturownia_pending_clients as $row ): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<?= $esc( $row['external_name'] ); ?><br>
|
||||||
|
<small class="text-muted">Klucz: <?= $esc( $row['external_key'] ); ?> | wystapienia: <?= (int)$row['hits']; ?></small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/finances/fakturownia_client_mapping_save/" class="form-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= \S::csrf_token(); ?>">
|
||||||
|
<input type="hidden" name="external_key" value="<?= $esc( $row['external_key'] ); ?>">
|
||||||
|
<input type="hidden" name="external_name" value="<?= $esc( $row['external_name'] ); ?>">
|
||||||
|
<select name="crm_client_id" class="form-control input-sm js-fakturownia-client-select" required>
|
||||||
|
<option value="">Wybierz klienta</option>
|
||||||
|
<? foreach ( $this -> fakturownia_crm_clients as $client ): ?>
|
||||||
|
<option value="<?= (int)$client['id']; ?>"><?= $esc( $client['firm'] ); ?></option>
|
||||||
|
<? endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" class="btn btn-success btn-sm">Zapisz</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<? endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<? endif; ?>
|
||||||
|
|
||||||
|
<? if ( is_array( $this -> fakturownia_pending_items ) && count( $this -> fakturownia_pending_items ) ): ?>
|
||||||
|
<h5 class="mt20">Brakujace mapowania produktow/uslug</h5>
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pozycja z faktury</th>
|
||||||
|
<th>Kategoria finansowa</th>
|
||||||
|
<th style="width: 130px;">Akcja</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<? foreach ( $this -> fakturownia_pending_items as $row ): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<?= $esc( $row['external_name'] ); ?><br>
|
||||||
|
<small class="text-muted">Klucz: <?= $esc( $row['external_key'] ); ?> | wystapienia: <?= (int)$row['hits']; ?></small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/finances/fakturownia_item_mapping_save/" class="form-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= \S::csrf_token(); ?>">
|
||||||
|
<input type="hidden" name="external_key" value="<?= $esc( $row['external_key'] ); ?>">
|
||||||
|
<input type="hidden" name="external_name" value="<?= $esc( $row['external_name'] ); ?>">
|
||||||
|
<select name="finance_category_id" class="form-control input-sm" required>
|
||||||
|
<option value="">Wybierz kategorie</option>
|
||||||
|
<? foreach ( $this -> fakturownia_categories as $category ): ?>
|
||||||
|
<option value="<?= (int)$category['id']; ?>">
|
||||||
|
<?= $esc( $category['name'] ); ?> (grupa: <?= (int)$category['group_id']; ?>)
|
||||||
|
</option>
|
||||||
|
<? endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" class="btn btn-success btn-sm">Zapisz</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<? endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<? endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function() {
|
||||||
|
if ( typeof jQuery === 'undefined' || !jQuery.fn.select2 )
|
||||||
|
return;
|
||||||
|
|
||||||
|
var selects = jQuery( '.js-fakturownia-client-select' );
|
||||||
|
if ( !selects.length )
|
||||||
|
return;
|
||||||
|
|
||||||
|
selects.select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
width: '100%',
|
||||||
|
placeholder: 'Wyszukaj klienta CRM'
|
||||||
|
});
|
||||||
|
|
||||||
|
selects.on( 'select2:open', function() {
|
||||||
|
setTimeout( function() {
|
||||||
|
var searchField = document.querySelector( '.select2-container--open .select2-search__field' );
|
||||||
|
if ( searchField )
|
||||||
|
searchField.focus();
|
||||||
|
}, 0 );
|
||||||
|
} );
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="finance-import-panel" style="clear: both; margin-top: 15px;">
|
||||||
|
<? include 'fakturownia-import-panel.php'; ?>
|
||||||
|
</div>
|
||||||
<div class="finance-manager">
|
<div class="finance-manager">
|
||||||
<div class="column-left">
|
<div class="column-left">
|
||||||
<div class="clients-list-container">
|
<div class="clients-list-container">
|
||||||
|
|||||||
@@ -1,48 +1,70 @@
|
|||||||
<div class="tasks_main_view">
|
<div class="tasks_main_view">
|
||||||
<div class="_left_column">
|
<div class="_left_column">
|
||||||
<select name="filtr" class="form-control">
|
<div class="filters_panel">
|
||||||
<option value="">--- wybierz filtr ---</option>
|
<div class="filters_panel_header">
|
||||||
<? foreach ( $this -> tasks_filtrs as $filtr ):?>
|
<div class="filters_panel_title">
|
||||||
<option value="<?= $filtr[ 'id' ];?>"><?= $filtr[ 'name' ];?></option>
|
<i class="fa fa-filter"></i>
|
||||||
<? endforeach;?>
|
<h3>Filtry</h3>
|
||||||
</select>
|
</div>
|
||||||
<div class="_buttons">
|
<span class="active_filters_badge">Aktywne: <strong id="active_filters_count">0</strong></span>
|
||||||
<a href="#" class="btn btn-success btn_small" id="_new_filtr">zapisz</a>
|
</div>
|
||||||
<a href="#" class="btn btn-primary btn_small" id="_update_filtr">aktualizuj</a>
|
<div class="filters_controls">
|
||||||
<!-- set default -->
|
<label class="filters_label" for="tasks_saved_filter">Zapisany filtr</label>
|
||||||
<a href="#" class="btn btn-dark btn_small" id="_set_default_filtr">domyślny</a>
|
<select name="filtr" id="tasks_saved_filter" class="form-control">
|
||||||
</div>
|
<option value="">--- wybierz filtr ---</option>
|
||||||
<div class="_projects">
|
<? foreach ( $this -> tasks_filtrs as $filtr ):?>
|
||||||
<h4>Projekty</h4>
|
<option value="<?= $filtr[ 'id' ];?>"><?= $filtr[ 'name' ];?></option>
|
||||||
<? foreach ( $this -> projects as $project ):?>
|
<? endforeach;?>
|
||||||
<div class="_project">
|
</select>
|
||||||
<div class="project_row">
|
<div class="_buttons">
|
||||||
<label for="project_<?= $project[ 'id' ];?>">
|
<a href="#" class="btn btn-success btn_small" id="_new_filtr"><i class="fa fa-plus"></i>Zapisz</a>
|
||||||
<input type="checkbox" class="g-checkbox" name="projects" value="<?= $project[ 'id' ];?>" <? if ( is_array( $this -> selected_projects ) and in_array( $project['id'], $this -> selected_projects ) ):?>checked<? endif;?>>
|
<a href="#" class="btn btn-primary btn_small" id="_update_filtr"><i class="fa fa-refresh"></i>Aktualizuj</a>
|
||||||
<?= $project[ 'name' ];?> <span class="project_count">(<?= (int)$project['total_tasks'];?>)</span>
|
<!-- set default -->
|
||||||
</label>
|
<a href="#" class="btn btn-dark btn_small" id="_set_default_filtr"><i class="fa fa-star-o"></i>Domyślny</a>
|
||||||
<? if ( \controls\Users::permissions( $this -> user['id'], 'projects', 'project_delete' ) ):?>
|
</div>
|
||||||
<a href="#" class="project_delete_inline" project_id="<?= (int)$project['id'];?>" project_name="<?= htmlspecialchars( $project['name'] );?>" title="Usuń projekt">
|
</div>
|
||||||
<i class="fa fa-trash"></i>
|
<div class="filters_section _projects">
|
||||||
</a>
|
<div class="filters_section_header">
|
||||||
<? endif;?>
|
<h4>Projekty</h4>
|
||||||
|
<input type="text" class="form-control filters_search" data-filter-target="projects" placeholder="Szukaj projektu...">
|
||||||
|
</div>
|
||||||
|
<div class="filters_section_list">
|
||||||
|
<? foreach ( $this -> projects as $project ):?>
|
||||||
|
<div class="_project filters_item" data-filter-item="projects">
|
||||||
|
<div class="project_row">
|
||||||
|
<label for="project_<?= $project[ 'id' ];?>">
|
||||||
|
<input id="project_<?= $project[ 'id' ];?>" type="checkbox" class="g-checkbox" name="projects" value="<?= $project[ 'id' ];?>" <? if ( is_array( $this -> selected_projects ) and in_array( $project['id'], $this -> selected_projects ) ):?>checked<? endif;?>>
|
||||||
|
<?= $project[ 'name' ];?> <span class="project_count">(<?= (int)$project['total_tasks'];?>)</span>
|
||||||
|
</label>
|
||||||
|
<? if ( \controls\Users::permissions( $this -> user['id'], 'projects', 'project_delete' ) ):?>
|
||||||
|
<a href="#" class="project_delete_inline" project_id="<?= (int)$project['id'];?>" project_name="<?= htmlspecialchars( $project['name'] );?>" title="Usuń projekt">
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
<? endif;?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<? endforeach;?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<? if ( $this -> user['id'] == 1 ):?>
|
||||||
|
<div class="filters_section _users">
|
||||||
|
<div class="filters_section_header">
|
||||||
|
<h4>Użytkownicy</h4>
|
||||||
|
<input type="text" class="form-control filters_search" data-filter-target="users" placeholder="Szukaj użytkownika...">
|
||||||
|
</div>
|
||||||
|
<div class="filters_section_list">
|
||||||
|
<? foreach ( $this -> users as $user ):?>
|
||||||
|
<div class="_user filters_item" data-filter-item="users">
|
||||||
|
<label for="user_<?= $user[ 'id' ];?>">
|
||||||
|
<input id="user_<?= $user[ 'id' ];?>" type="checkbox" class="g-checkbox" name="users" value="<?= $user[ 'id' ];?>" <? if ( is_array( $this -> selected_users ) and in_array( $user['id'], $this -> selected_users ) ):?>checked<? endif;?>>
|
||||||
|
<?= $user[ 'name' ];?> <?= $user[ 'surname' ];?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<? endforeach;?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<? endforeach;?>
|
<? endif;?>
|
||||||
</div>
|
</div>
|
||||||
<? if ( $this -> user['id'] == 1 ):?>
|
|
||||||
<div class="_users">
|
|
||||||
<h4>Użytkownicy</h4>
|
|
||||||
<? foreach ( $this -> users as $user ):?>
|
|
||||||
<div class="_user">
|
|
||||||
<label for="user_<?= $user[ 'id' ];?>">
|
|
||||||
<input type="checkbox" class="g-checkbox" name="users" value="<?= $user[ 'id' ];?>" <? if ( is_array( $this -> selected_users ) and in_array( $user['id'], $this -> selected_users ) ):?>checked<? endif;?>>
|
|
||||||
<?= $user[ 'name' ];?> <?= $user[ 'surname' ];?>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<? endforeach;?>
|
|
||||||
</div>
|
|
||||||
<? endif;?>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="_right_column">
|
<div class="_right_column">
|
||||||
<div class="action_menu">
|
<div class="action_menu">
|
||||||
@@ -134,79 +156,23 @@
|
|||||||
<div class="task_popup">
|
<div class="task_popup">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<style type="text/css">
|
|
||||||
.tasks_main_view ._left_column {
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 350px;
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._right_column {
|
|
||||||
flex: 1;
|
|
||||||
max-width: none;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._left_column ._projects ._project .project_row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._left_column ._projects ._project .project_row label {
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._left_column ._projects ._project .project_row .project_count {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 3px;
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #1f3d72;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 18px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._left_column ._projects ._project .project_delete_inline {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #cc563d;
|
|
||||||
background: #cc563d;
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all .2s ease;
|
|
||||||
margin: 1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._left_column ._projects ._project .project_delete_inline:hover {
|
|
||||||
background: #b74831;
|
|
||||||
border-color: #b74831;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks_main_view ._left_column ._projects ._project .project_delete_inline i {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-target .gantt-container.gantt-draggable {
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-target .gantt-container.gantt-dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
let isProgrammaticUpdate = false;
|
let isProgrammaticUpdate = false;
|
||||||
|
function updateActiveFiltersCounter()
|
||||||
|
{
|
||||||
|
var activeFiltersCount = $( '.tasks_main_view input.g-checkbox:checked' ).length;
|
||||||
|
$( '#active_filters_count' ).text( activeFiltersCount );
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiltersSearch( target, value )
|
||||||
|
{
|
||||||
|
var normalizedValue = value.toLowerCase().trim();
|
||||||
|
$( '.tasks_main_view [data-filter-item="' + target + '"]' ).each(function() {
|
||||||
|
var itemText = $( this ).text().toLowerCase();
|
||||||
|
var shouldShow = normalizedValue === '' || itemText.indexOf( normalizedValue ) !== -1;
|
||||||
|
$( this ).toggle( shouldShow );
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var tasks = [
|
var tasks = [
|
||||||
<?
|
<?
|
||||||
@@ -1368,7 +1334,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
isProgrammaticUpdate = false;
|
isProgrammaticUpdate = false;
|
||||||
// wywołuj z tablicami, nie ze stringami
|
updateActiveFiltersCounter();
|
||||||
|
// wywoluj z tablicami, nie ze stringami
|
||||||
reload_tasks(projectsArr, usersArr);
|
reload_tasks(projectsArr, usersArr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1512,6 +1479,13 @@
|
|||||||
radioClass: 'iradio_square-blue',
|
radioClass: 'iradio_square-blue',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateActiveFiltersCounter();
|
||||||
|
|
||||||
|
$( '.tasks_main_view' ).on( 'input', '.filters_search', function() {
|
||||||
|
var target = $( this ).attr( 'data-filter-target' );
|
||||||
|
applyFiltersSearch( target, $( this ).val() );
|
||||||
|
});
|
||||||
|
|
||||||
$(".tasks_main_view input.g-checkbox").on('ifChanged', function (e) {
|
$(".tasks_main_view input.g-checkbox").on('ifChanged', function (e) {
|
||||||
$(this).trigger("change", e);
|
$(this).trigger("change", e);
|
||||||
});
|
});
|
||||||
@@ -1529,6 +1503,7 @@
|
|||||||
}).get();
|
}).get();
|
||||||
projects.join( "," );
|
projects.join( "," );
|
||||||
users.join( "," );
|
users.join( "," );
|
||||||
|
updateActiveFiltersCounter();
|
||||||
reload_tasks( projects, users );
|
reload_tasks( projects, users );
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user