update
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.scannerwork/
|
||||
.env
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
# Roadmap: crmPRO
|
||||
# Roadmap: crmPRO
|
||||
|
||||
## 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
|
||||
**v0.1 Stabilizacja i jakość kodu** (v0.1.0)
|
||||
**v0.1 Stabilizacja i jakosc kodu + import finansow** (v0.1.0)
|
||||
Status: In progress
|
||||
Phases: 1 of 4 complete
|
||||
Phases: 1 of 5 complete
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Plans | Status | Completed |
|
||||
|-------|------|-------|--------|-----------|
|
||||
| 1 | Konfiguracja SonarQube i baseline | 1/1 | ✅ Complete | 2026-03-15 |
|
||||
| 2 | Naprawa błędów krytycznych | TBD | 🔵 Next | - |
|
||||
| 3 | Naprawa błędów głównych | TBD | Not started | - |
|
||||
| 1 | Konfiguracja SonarQube i baseline | 1/1 | Complete | 2026-03-15 |
|
||||
| 2 | Naprawa bledow krytycznych | TBD | Next | - |
|
||||
| 3 | Naprawa bledow glownych | TBD | Not started | - |
|
||||
| 4 | Poprawa pokrycia testami | TBD | Not started | - |
|
||||
| 5 | Import finansow z Fakturowni | 0/1 | Planning | - |
|
||||
|
||||
## 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)
|
||||
**Completed:** 2026-03-15
|
||||
|
||||
**Results:**
|
||||
- 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
|
||||
- Reliability: D, Security: A, Maintainability: A
|
||||
|
||||
**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)
|
||||
**Research:** Unlikely
|
||||
|
||||
@@ -46,11 +47,11 @@ Phases: 1 of 4 complete
|
||||
- Reskan po naprawach
|
||||
|
||||
**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
|
||||
**Research:** Unlikely
|
||||
|
||||
@@ -60,21 +61,36 @@ Phases: 1 of 4 complete
|
||||
- Reskan po naprawach
|
||||
|
||||
**Plans:**
|
||||
- [ ] 03-01: TBD (na podstawie wyników Phase 2)
|
||||
- [ ] 03-01: TBD (na podstawie wynikow Phase 2)
|
||||
|
||||
### 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
|
||||
**Research:** Unlikely
|
||||
|
||||
**Scope:**
|
||||
- Testy dla modułów o najniższym pokryciu
|
||||
- Testy regresji dla naprawionych bugów
|
||||
- Testy dla modulow o najnizszym pokryciu
|
||||
- Testy regresji dla naprawionych bugow
|
||||
|
||||
**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*
|
||||
*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
|
||||
|
||||
See: .paul/PROJECT.md (updated 2026-03-15)
|
||||
|
||||
**Core value:** Użytkownicy mogą efektywnie zarządzać projektami, zadaniami i klientami w jednym systemie CRM
|
||||
**Current focus:** Faza 2 — Naprawa błędów krytycznych
|
||||
**Core value:** Uzytkownicy moga efektywnie zarzadzac projektami, zadaniami i klientami w jednym systemie CRM
|
||||
**Current focus:** Faza 5 - Import finansow z Fakturowni
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v0.1 Stabilizacja i jakość kodu
|
||||
Phase: 2 of 4 (Naprawa błędów krytycznych) — In Progress
|
||||
Plan: 02-02 complete, 02-01 awaiting approval
|
||||
Status: Loop closed for 02-02, ready for next plan
|
||||
Last activity: 2026-03-15 — Completed .paul/phases/02-critical-bugs-fix/02-02-SUMMARY.md
|
||||
Milestone: v0.1 Stabilizacja i jakosc kodu
|
||||
Phase: 5 of 5 (Import finansow z Fakturowni) - Apply complete
|
||||
Plan: 05-01 executed, awaiting unify
|
||||
Status: APPLY complete, ready for UNIFY
|
||||
Last activity: 2026-04-02 11:03 - Executed .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
|
||||
|
||||
Progress:
|
||||
- Milestone: [██░░░░░░░░] 25%
|
||||
- Phase 2: [█████░░░░░] 50% (1/2 plans complete)
|
||||
- Milestone: [#######---] 35%
|
||||
- Phase 5: [########--] 80%
|
||||
|
||||
## Loop Position
|
||||
|
||||
Current loop state:
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
✓ ✓ ✓ [Loop complete for 02-02 — ready for next PLAN]
|
||||
PLAN --> APPLY --> UNIFY
|
||||
✓ ✓ ○ [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
|
||||
|
||||
Last session: 2026-03-15
|
||||
Stopped at: Plan 02-02 UNIFY complete
|
||||
Next action: Approve and execute plan 02-01 (SonarQube bugs fix) — run /paul:apply .paul/phases/02-critical-bugs-fix/02-01-PLAN.md
|
||||
Resume file: .paul/phases/02-critical-bugs-fix/02-02-SUMMARY.md
|
||||
Last session: 2026-04-02 11:03
|
||||
Stopped at: Apply finished for plan 05-01
|
||||
Next action: Run $paul-unify .paul/phases/05-finances-fakturownia-import/05-01-PLAN.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": {
|
||||
"type": "-",
|
||||
"size": 47213,
|
||||
"lmtime": 1773530856493,
|
||||
"size": 49131,
|
||||
"lmtime": 1774532986644,
|
||||
"modified": false
|
||||
},
|
||||
"task_edit.php": {
|
||||
@@ -693,9 +693,9 @@
|
||||
},
|
||||
"task_popup.php": {
|
||||
"type": "-",
|
||||
"size": 42366,
|
||||
"size": 45012,
|
||||
"lmtime": 1773530848546,
|
||||
"modified": false
|
||||
"modified": true
|
||||
},
|
||||
"task_single.php": {
|
||||
"type": "-",
|
||||
@@ -703,11 +703,17 @@
|
||||
"lmtime": 1772276304856,
|
||||
"modified": false
|
||||
},
|
||||
"task_work_logs_popup.php": {
|
||||
"type": "-",
|
||||
"size": 2629,
|
||||
"lmtime": 0,
|
||||
"modified": false
|
||||
},
|
||||
"work-time.php": {
|
||||
"type": "-",
|
||||
"size": 12763,
|
||||
"size": 15966,
|
||||
"lmtime": 1771236164971,
|
||||
"modified": false
|
||||
"modified": true
|
||||
}
|
||||
},
|
||||
"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();
|
||||
}
|
||||
|
||||
private static function importRepo()
|
||||
{
|
||||
return new \Domain\Finances\FakturowniaImportRepository();
|
||||
}
|
||||
|
||||
private static function requireAuth()
|
||||
{
|
||||
global $user;
|
||||
@@ -53,6 +58,8 @@ class FinancesController
|
||||
return false;
|
||||
|
||||
$repo = self::repo();
|
||||
$importRepo = self::importRepo();
|
||||
$importRepo -> ensureTables();
|
||||
|
||||
if ( \S::get( 'tag-clear' ) )
|
||||
unset( $_SESSION['finance-tag-id'] );
|
||||
@@ -93,10 +100,121 @@ class FinancesController
|
||||
'wallet_summary' => $repo -> walletSummary( $group_id ),
|
||||
'wallet_summary_this_month' => $repo -> walletSummaryThisMonth( $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()
|
||||
{
|
||||
if ( !self::requireAuth() )
|
||||
@@ -230,4 +348,70 @@ class FinancesController
|
||||
'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' ] ] );
|
||||
}
|
||||
|
||||
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 )
|
||||
{
|
||||
return $this -> mdb -> query(
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
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()
|
||||
{
|
||||
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 );
|
||||
|
||||
function __autoload_my_classes( $classname )
|
||||
@@ -23,6 +23,7 @@ require_once 'libraries/grid/config.php';
|
||||
require_once 'libraries/rb.php';
|
||||
|
||||
session_start();
|
||||
\Env::load();
|
||||
|
||||
if ( !isset( $_SESSION['check'] ) )
|
||||
{
|
||||
@@ -53,6 +54,27 @@ $mdb = new medoo( [
|
||||
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 */
|
||||
$response = \Cron::import_tasks_from_email();
|
||||
if ( $response['status'] == 'ok' )
|
||||
@@ -66,10 +88,9 @@ if ( $response['status'] == 'error' )
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
if ( date( 'G' ) > 6 )
|
||||
{
|
||||
/* wysyłanie przypomnnień do zadań */
|
||||
/* wysylanie przypomnien do zadan */
|
||||
$response = \Cron::tasks_emails();
|
||||
if ( $response['status'] == 'ok' )
|
||||
{
|
||||
@@ -78,7 +99,7 @@ if ( date( 'G' ) > 6 )
|
||||
}
|
||||
}
|
||||
|
||||
/* dodawanie zadań rekursywnych */
|
||||
/* dodawanie zadan rekursywnych */
|
||||
$response = \Cron::recursive_tasks();
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 200px 1fr 500px;
|
||||
grid-template-columns: 250px 1fr 500px;
|
||||
padding-top: 25px;
|
||||
|
||||
.manage-menu {
|
||||
@@ -1999,36 +1999,248 @@ $sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
.tasks_main_view {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
|
||||
._left_column {
|
||||
padding: 25px;
|
||||
background: #fcfcfc;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
width: 350px;
|
||||
width: fit-content;
|
||||
min-width: 350px;
|
||||
max-width: 520px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-right: 0;
|
||||
|
||||
label {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
.filters_panel {
|
||||
background: linear-gradient(180deg, #fdfefe 0%, #f4f7fb 100%);
|
||||
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"] {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.btn {
|
||||
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 {
|
||||
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 class="finance-import-panel" style="clear: both; margin-top: 15px;">
|
||||
<? include 'fakturownia-import-panel.php'; ?>
|
||||
</div>
|
||||
<div class="finance-manager">
|
||||
<div class="column-left">
|
||||
<div class="clients-list-container">
|
||||
@@ -307,4 +310,4 @@ $(function() {
|
||||
$(this).parents('.input-group').children('input').trigger('click');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,48 +1,70 @@
|
||||
<div class="tasks_main_view">
|
||||
<div class="_left_column">
|
||||
<select name="filtr" class="form-control">
|
||||
<option value="">--- wybierz filtr ---</option>
|
||||
<? foreach ( $this -> tasks_filtrs as $filtr ):?>
|
||||
<option value="<?= $filtr[ 'id' ];?>"><?= $filtr[ 'name' ];?></option>
|
||||
<? endforeach;?>
|
||||
</select>
|
||||
<div class="_buttons">
|
||||
<a href="#" class="btn btn-success btn_small" id="_new_filtr">zapisz</a>
|
||||
<a href="#" class="btn btn-primary btn_small" id="_update_filtr">aktualizuj</a>
|
||||
<!-- set default -->
|
||||
<a href="#" class="btn btn-dark btn_small" id="_set_default_filtr">domyślny</a>
|
||||
</div>
|
||||
<div class="_projects">
|
||||
<h4>Projekty</h4>
|
||||
<? foreach ( $this -> projects as $project ):?>
|
||||
<div class="_project">
|
||||
<div class="project_row">
|
||||
<label for="project_<?= $project[ 'id' ];?>">
|
||||
<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;?>>
|
||||
<?= $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 class="filters_panel">
|
||||
<div class="filters_panel_header">
|
||||
<div class="filters_panel_title">
|
||||
<i class="fa fa-filter"></i>
|
||||
<h3>Filtry</h3>
|
||||
</div>
|
||||
<span class="active_filters_badge">Aktywne: <strong id="active_filters_count">0</strong></span>
|
||||
</div>
|
||||
<div class="filters_controls">
|
||||
<label class="filters_label" for="tasks_saved_filter">Zapisany filtr</label>
|
||||
<select name="filtr" id="tasks_saved_filter" class="form-control">
|
||||
<option value="">--- wybierz filtr ---</option>
|
||||
<? foreach ( $this -> tasks_filtrs as $filtr ):?>
|
||||
<option value="<?= $filtr[ 'id' ];?>"><?= $filtr[ 'name' ];?></option>
|
||||
<? endforeach;?>
|
||||
</select>
|
||||
<div class="_buttons">
|
||||
<a href="#" class="btn btn-success btn_small" id="_new_filtr"><i class="fa fa-plus"></i>Zapisz</a>
|
||||
<a href="#" class="btn btn-primary btn_small" id="_update_filtr"><i class="fa fa-refresh"></i>Aktualizuj</a>
|
||||
<!-- set default -->
|
||||
<a href="#" class="btn btn-dark btn_small" id="_set_default_filtr"><i class="fa fa-star-o"></i>Domyślny</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters_section _projects">
|
||||
<div class="filters_section_header">
|
||||
<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>
|
||||
<? endforeach;?>
|
||||
<? endif;?>
|
||||
</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 class="_right_column">
|
||||
<div class="action_menu">
|
||||
@@ -134,79 +156,23 @@
|
||||
<div class="task_popup">
|
||||
|
||||
</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">
|
||||
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 = [
|
||||
<?
|
||||
@@ -1368,7 +1334,8 @@
|
||||
});
|
||||
|
||||
isProgrammaticUpdate = false;
|
||||
// wywołuj z tablicami, nie ze stringami
|
||||
updateActiveFiltersCounter();
|
||||
// wywoluj z tablicami, nie ze stringami
|
||||
reload_tasks(projectsArr, usersArr);
|
||||
}
|
||||
}
|
||||
@@ -1512,6 +1479,13 @@
|
||||
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) {
|
||||
$(this).trigger("change", e);
|
||||
});
|
||||
@@ -1529,6 +1503,7 @@
|
||||
}).get();
|
||||
projects.join( "," );
|
||||
users.join( "," );
|
||||
updateActiveFiltersCounter();
|
||||
reload_tasks( projects, users );
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user