From 63561a8f933ab7d3412d0c33e4d7e2c2d6bc5ff7 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Mon, 3 Aug 2026 20:07:59 +0200 Subject: [PATCH] Fakturace --- app/Model/Facade/FakturaFacade.php | 212 +++++++++++++++++ app/UI/@layout.latte | 4 +- app/UI/Faktura/FakturaPresenter.php | 85 ++----- app/UI/Faktura/default.latte | 222 +++++++++--------- .../FakturaDetail/FakturaDetailPresenter.php | 37 +++ app/UI/FakturaDetail/default.latte | 150 ++++++++++++ app/UI/FakturaEdit/FakturaEditPresenter.php | 163 +++++++++++++ app/UI/FakturaEdit/edit.latte | 217 +++++++++++++++++ sql/KufrikDB.sql | 3 +- 9 files changed, 919 insertions(+), 174 deletions(-) create mode 100644 app/Model/Facade/FakturaFacade.php create mode 100644 app/UI/FakturaDetail/FakturaDetailPresenter.php create mode 100644 app/UI/FakturaDetail/default.latte create mode 100644 app/UI/FakturaEdit/FakturaEditPresenter.php create mode 100644 app/UI/FakturaEdit/edit.latte diff --git a/app/Model/Facade/FakturaFacade.php b/app/Model/Facade/FakturaFacade.php new file mode 100644 index 0000000..6ca61a7 --- /dev/null +++ b/app/Model/Facade/FakturaFacade.php @@ -0,0 +1,212 @@ +kufrikExplorer->query($sql, ...$params)->fetchAll(); + } + + /** + * Detail jedné faktury podle ID + */ + public function getById(int $id) + { + return $this->kufrikExplorer->table('FAKTURA')->where('ID', $id)->fetch(); + } + + /** + * Položky faktury seřazené podle PORADI + */ + public function getPolozky(int $fakturaId): array + { + return $this->kufrikExplorer->query(" + SELECT P.* + FROM dbo.POLOZKA P + WHERE P.FAKTURA = ? + ORDER BY P.PORADI ASC, P.ID ASC + ", $fakturaId)->fetchAll(); + } + + /** + * Vygeneruje následující číslo faktury pro aktuální rok a firmu + */ + public function getNextCislo(int $firmaId): string + { + $aktualniRok = (int) date('Y'); + + // Najdeme nejvyšší CISLO_ABS pro daný rok a firmu + $maxAbs = $this->kufrikExplorer->query(" + SELECT MAX(CISLO_ABS) AS MAX_ABS + FROM dbo.FAKTURA + WHERE FIRMA_ID = ? AND YEAR(DATUM_VYSTAVENI) = ? + ", $firmaId, $aktualniRok)->fetch()?->MAX_ABS; + + // Pokud ještě v tomto roce žádná faktura neexistuje, začínáme od 1 + $dalsiCislo = ($maxAbs !== null) ? ((int) $maxAbs + 1) : 1; + + // Formátování: RRRR + třímístné číslo doplněné nulami (např. 2026001, 2026002...) + return $aktualniRok . str_pad((string) $dalsiCislo, 3, '0', STR_PAD_LEFT); + } + + /** + * Uložení / Aktualizace hlavičky faktury + vytvoření snapshotů D_* a O_* + */ + public function saveFaktura(array $data, ?int $id = null, int $firmaId): int + { + if ($id) { + // PŘI EDITACI: Mění se pouze měnitelné údaje (splatnost, DUZP, stav, činnosti) + $values = [ + 'DATUM_SPLATNOSTI' => $data['DATUM_SPLATNOSTI'], + 'DATUM_DUZP' => !empty($data['DATUM_DUZP']) ? $data['DATUM_DUZP'] : null, + 'STAV' => $data['STAV'] ?? 'ISSUED', + 'CINNOSTI' => !empty($data['CINNOSTI']) ? $data['CINNOSTI'] : null, + ]; + + $this->kufrikExplorer->table('FAKTURA')->where('ID', $id)->update($values); + return $id; + + } else { + // PŘI NOVÉ FAKTUŘE: Ukládáme vše (včetně DATUM_VYSTAVENI, CISLO, ZAKAZNIK a snapshotů) + $firma = $this->kufrikExplorer->table('FIRMA')->where('ID', $firmaId)->fetch(); + $zakaznikId = !empty($data['ZAKAZNIK_ID']) ? (int) $data['ZAKAZNIK_ID'] : null; + $zakaznik = $zakaznikId ? $this->kufrikExplorer->table('ZAKAZNIK')->where('ID', $zakaznikId)->fetch() : null; + + $oPrajm = $zakaznik ? trim(($zakaznik->PRIJMENI ?? '') . ' ' . ($zakaznik->JMENO ?? '')) : null; + + $dBanka = null; + if ($firma) { + if (isset($firma->UCET, $firma->KOD_BANKY) && $firma->UCET) { + $dBanka = $firma->UCET . '/' . $firma->KOD_BANKY; + } elseif (isset($firma->BANKA)) { + $dBanka = $firma->BANKA; + } + } + + $values = [ + 'FIRMA_ID' => $firmaId, + 'ZAKAZNIK' => $zakaznikId, + 'CISLO' => $data['CISLO'], + 'CISLO_ABS' => (int) ($data['CISLO_ABS'] ?? substr($data['CISLO'], -3)), + 'FORMA_UHRADY' => (int) ($data['FORMA_UHRADY'] ?? 1), + 'DATUM_VYSTAVENI' => $data['DATUM_VYSTAVENI'], + 'DATUM_SPLATNOSTI' => $data['DATUM_SPLATNOSTI'], + 'DATUM_DUZP' => !empty($data['DATUM_DUZP']) ? $data['DATUM_DUZP'] : null, + 'STAV' => $data['STAV'] ?? 'ISSUED', + 'CINNOSTI' => !empty($data['CINNOSTI']) ? $data['CINNOSTI'] : null, + + // SNAPSHOT DODAVATELE + 'D_PRAJM' => $firma->NAZEV ?? null, + 'D_ULICE' => $firma->ULICE ?? null, + 'D_MESTO' => $firma->MESTO ?? null, + 'D_PSC' => $firma->PSC ?? null, + 'D_IC' => $firma->IC ?? null, + 'D_DIC' => $firma->DIC ?? null, + 'D_BANKA' => $dBanka, + 'D_TELEFON' => $firma->TELEFON ?? null, + 'D_EMAIL' => $firma->EMAIL ?? null, + 'D_WEB' => $firma->WEB ?? null, + + // SNAPSHOT ODBĚRATELE + 'O_PRAJM' => $oPrajm, + 'O_ULICE' => $zakaznik->ULICE ?? null, + 'O_MESTO' => $zakaznik->MESTO ?? null, + 'O_PSC' => $zakaznik->PSC ?? null, + 'O_IC' => $zakaznik->IC ?? null, + 'O_DIC' => $zakaznik->DIC ?? null, + ]; + + $row = $this->kufrikExplorer->table('FAKTURA')->insert($values); + return $row->ID; + } + } + + /** + * Uložení položek s automatickým výpočtem PORADI + */ + public function savePolozky(int $fakturaId, array $polozkyData, int $firmaId): void + { + $this->kufrikExplorer->table('POLOZKA')->where('FAKTURA', $fakturaId)->delete(); + + $poradi = 1; + foreach ($polozkyData as $item) { + if (empty($item['NAZEV']) && empty($item['CENA_JEDNOTKA'])) { + continue; + } + + $pocet = (float) ($item['POCET'] ?? 1); + $cenaJednotka = (float) ($item['CENA_JEDNOTKA'] ?? 0); + $sleva = (float) ($item['SLEVA'] ?? 0); + $cenaCelkem = ($pocet * $cenaJednotka) * (1 - ($sleva / 100)); + + $this->kufrikExplorer->table('POLOZKA')->insert([ + 'FAKTURA' => $fakturaId, + 'FIRMA_ID' => $firmaId, + 'PORADI' => $poradi++, + 'NAZEV' => $item['NAZEV'], + 'POCET' => $pocet, + 'JEDNOTKA' => $item['JEDNOTKA'] ?: 'ks', + 'CENA_JEDNOTKA' => $cenaJednotka, + 'SLEVA' => $sleva, + 'CENA' => $cenaCelkem, + ]); + } + } + + public function toggleUhrada(int $id): string + { + $faktura = $this->getById($id); + if (!$faktura) { + return ''; + } + + $novyStav = ($faktura->STAV === 'PAID') ? 'ISSUED' : 'PAID'; + + $this->kufrikExplorer->table('FAKTURA') + ->where('ID', $id) + ->update(['STAV' => $novyStav]); + + return $novyStav; + } + + public function delete(int $id): void + { + $this->kufrikExplorer->table('FAKTURA')->where('ID', $id)->delete(); + } +} \ No newline at end of file diff --git a/app/UI/@layout.latte b/app/UI/@layout.latte index 47052e3..81cfc58 100644 --- a/app/UI/@layout.latte +++ b/app/UI/@layout.latte @@ -113,10 +113,10 @@ Seznam faktur - + +
+ +
+

Přehled faktur

+ + Vystavit fakturu +
-
-
-
-
-
-
Celkem fakturováno ({$vybranyRok}{if $vybranyMesic}/{$vybranyMesic}{/if})
-
{$celkemSum|number:2, ',', ' '} CZK
-
+
+
+
+
Celkem v tomto období
+
{$celkemSum|number:2, ',', ' '} CZK
-
-
-
-
-
Neuhrazené faktury v období
-
{$neuhrazenoCount} ks
-
+
+
+
Neuhrazené faktury
+
{$neuhrazenoCount} ks
- -
-
-

- Vydané faktury -

- - Vystavit novou fakturu - -
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
- +
- + - + - + - {foreach $faktury as $faktura} + {foreach $faktury as $f} - - + + - - + + + + - - - - - {else} - - {/foreach} @@ -132,18 +137,23 @@
Číslo fakturyČíslo Odběratel Vystaveno SplatnostCelková částkaČástka StavAkceAkce
- - {$faktura->CISLO} + + + + {$f->CISLO} -
{$faktura->O_PRAJM ?: 'Nespecifikovaný odběratel'}
-
IČ: {$faktura->O_IC}
+ +
{$f->O_PRAJM ?: '—'} + {$f->DATUM_VYSTAVENI|date:'d.m.Y'} {$faktura->DATUM_VYSTAVENI|date:'d.m.Y'} - - {$faktura->DATUM_SPLATNOSTI|date:'d.m.Y'} + + + {$f->DATUM_SPLATNOSTI|date:'d.m.Y'} + + {$f->CASTKA_CELKEM|number:2, ',', ' '} CZK + + + {if $f->STAV === 'PAID'}Zaplaceno{else}Neuhrazeno{/if} - {($faktura->CASTKA_CELKEM ?? 0)|number:2, ',', ' '} CZK - - {if $faktura->STAV === 'PAID'} - - Uhrazeno - - {else} - - Neuhrazeno - - {/if} - -
- - - - + +
+ + + + - - - - + + + + - - - - + + + + - - - - - -
- - Pro zadaný filtr (rok {$vybranyRok}{if $vybranyMesic}, měsíc {$vybranyMesic}{/if}) nebyly nalezeny žádné faktury. + + + +
-
+ + + + + +{syntax off} \ No newline at end of file +}); + +{/syntax} \ No newline at end of file diff --git a/app/UI/FakturaDetail/FakturaDetailPresenter.php b/app/UI/FakturaDetail/FakturaDetailPresenter.php new file mode 100644 index 0000000..b86c9da --- /dev/null +++ b/app/UI/FakturaDetail/FakturaDetailPresenter.php @@ -0,0 +1,37 @@ +fakturaFacade->getById($id); + + if (!$faktura) { + $this->flashMessage('Faktura nebyla nalezena.', 'danger'); + $this->redirect('Faktura:default'); + } + + $polozky = $this->fakturaFacade->getPolozky($id); + + // Výpočet celkové částky + $celkem = 0; + foreach ($polozky as $p) { + $celkem += (float) $p->CENA; + } + + $this->template->faktura = $faktura; + $this->template->polozky = $polozky; + $this->template->celkem = $celkem; + } +} \ No newline at end of file diff --git a/app/UI/FakturaDetail/default.latte b/app/UI/FakturaDetail/default.latte new file mode 100644 index 0000000..8d9620f --- /dev/null +++ b/app/UI/FakturaDetail/default.latte @@ -0,0 +1,150 @@ +{block content} + +
+ +
+ +
+ + Upravit + + +
+
+ + +
+ +
+
+

FAKTURA - DAŇOVÝ DOKLAD

+
Číslo: {$faktura->CISLO}
+
+
+ + {if $faktura->STAV === 'PAID'}UHRATENO{else}NEUHRATENO{/if} + +
+
+ + +
+ +
+
Dodavatel
+
{$faktura->D_PRAJM ?: '—'}
+
{$faktura->D_ULICE}
+
{$faktura->D_PSC} {$faktura->D_MESTO}
+ +
+ {if $faktura->D_IC}IČ: {$faktura->D_IC}
{/if} + {if $faktura->D_DIC}DIČ: {$faktura->D_DIC}
{/if} +
+ + {if $faktura->D_BANKA} +
+ Bankovní účet:
+ {$faktura->D_BANKA} +
+ {/if} +
+ + +
+
Odběratel
+
{$faktura->O_PRAJM ?: '—'}
+
{$faktura->O_ULICE}
+
{$faktura->O_PSC} {$faktura->O_MESTO}
+ +
+ {if $faktura->O_IC}IČ: {$faktura->O_IC}
{/if} + {if $faktura->O_DIC}DIČ: {$faktura->O_DIC}
{/if} +
+
+
+ + +
+
+
Datum vystavení:
+
{$faktura->DATUM_VYSTAVENI|date:'d.m.Y'}
+
+
+
Datum DUZP:
+
{if $faktura->DATUM_DUZP}{$faktura->DATUM_DUZP|date:'d.m.Y'}{else}—{/if}
+
+
+
Datum splatnosti:
+
{$faktura->DATUM_SPLATNOSTI|date:'d.m.Y'}
+
+
+ + +
+
Popis plnění / Činnosti:
+

{$faktura->CINNOSTI}

+
+ + +
+ + + + + + + + + + + + + + {foreach $polozky as $p} + + + + + + + + + + {/foreach} + +
#PoložkaPočetJedn.Cena/J.SlevaCelkem
{$p->PORADI}{$p->NAZEV}{$p->POCET}{$p->JEDNOTKA}{$p->CENA_JEDNOTKA|number:2, ',', ' '} CZK{if $p->SLEVA > 0}{$p->SLEVA} %{else}—{/if}{$p->CENA|number:2, ',', ' '} CZK
+
+ + +
+
+
+ Celkem k úhradě: + {$celkem|number:2, ',', ' '} CZK +
+
+
+
+
+ + \ No newline at end of file diff --git a/app/UI/FakturaEdit/FakturaEditPresenter.php b/app/UI/FakturaEdit/FakturaEditPresenter.php new file mode 100644 index 0000000..e2088ee --- /dev/null +++ b/app/UI/FakturaEdit/FakturaEditPresenter.php @@ -0,0 +1,163 @@ +id = null; + $this->setView('edit'); + + $firmaId = $this->getUser()->getIdentity()->firma_id; + $noveCislo = $this->fakturaFacade->getNextCislo($firmaId); + + // Předplníme nové číslo faktury do formuláře + $this['fakturaForm']->setDefaults([ + 'CISLO' => $noveCislo, + ]); + } + + public function actionEdit(int $id): void + { + $this->id = $id; + $faktura = $this->fakturaFacade->getById($id); + + if (!$faktura) { + $this->flashMessage('Faktura nebyla nalezena.', 'danger'); + $this->redirect('Faktura:default'); + } + + $defaults = $faktura->toArray(); + + // Explicitně přiřadíme ID zákazníka + if ($faktura->ZAKAZNIK !== null) { + $defaults['ZAKAZNIK_ID'] = (int) $faktura->ZAKAZNIK; + } + + if ($faktura->DATUM_VYSTAVENI) { + $defaults['DATUM_VYSTAVENI'] = $faktura->DATUM_VYSTAVENI->format('Y-m-d'); + } + if ($faktura->DATUM_SPLATNOSTI) { + $defaults['DATUM_SPLATNOSTI'] = $faktura->DATUM_SPLATNOSTI->format('Y-m-d'); + } + if ($faktura->DATUM_DUZP) { + $defaults['DATUM_DUZP'] = $faktura->DATUM_DUZP->format('Y-m-d'); + } + + // 1. Nastavení výchozích hodnot + $this['fakturaForm']->setDefaults($defaults); + + // Společný CSS styl pro zablokované / zešedlé prvky (odpovídá Bootstrap disabled vzhledu) + $disabledStyle = 'pointer-events: none; background-color: #e9ecef; opacity: 1;'; + + // 2. Zamknutí prvků při editaci (Odběratel, Číslo faktury, Datum vystavení) + $this['fakturaForm']['ZAKAZNIK_ID'] + ->setHtmlAttribute('style', $disabledStyle) + ->setHtmlAttribute('tabindex', '-1'); + + $this['fakturaForm']['CISLO'] + ->setHtmlAttribute('readonly', true) + ->setHtmlAttribute('style', $disabledStyle) + ->setHtmlAttribute('tabindex', '-1'); + + $this['fakturaForm']['DATUM_VYSTAVENI'] + ->setHtmlAttribute('readonly', true) + ->setHtmlAttribute('style', $disabledStyle) + ->setHtmlAttribute('tabindex', '-1'); + } + + public function renderEdit(?int $id = null): void + { + $this->template->isEdit = (bool) $this->id; + + // Pokud upravujeme, načteme stávající položky, jinak připravíme prázdné pole pro JS/Latte + $this->template->polozky = $this->id ? $this->fakturaFacade->getPolozky($this->id) : []; + } + + protected function createComponentFakturaForm(): Form + { + $form = new Form; + + // 1. Načteme všechny zákazníky (parametr 'vse' projde bez filtru AKTIVNI) + $zakaznici = $this->zakaznikFacade->getFiltered('', 'vse'); + $zakazniciPairs = []; + foreach ($zakaznici as $z) { + $nazev = trim(($z->PRIJMENI ?? '') . ' ' . ($z->JMENO ?? '')); + if (!empty($z->IC)) { + $nazev .= " (IČ: {$z->IC})"; + } + // Kličem musí být int + $zakazniciPairs[(int) $z->ID] = $nazev; + } + + $form->addSelect('ZAKAZNIK_ID', 'Odběratel:', $zakazniciPairs) + ->setPrompt('-- Vyberte zákazníka --'); + + $form->addText('CISLO', 'Číslo faktury:') + ->setDefaultValue(date('Y') . '001'); + + $form->addText('DATUM_VYSTAVENI', 'Datum vystavení:') + ->setHtmlType('date') + ->setDefaultValue(date('Y-m-d')); + + $form->addText('DATUM_SPLATNOSTI', 'Datum splatnosti:') + ->setHtmlType('date') + ->setDefaultValue(date('Y-m-d', strtotime('+14 days'))) + ->setRequired('Zadejte datum splatnosti.'); + + $form->addText('DATUM_DUZP', 'Datum UZP:') + ->setHtmlType('date'); + + $form->addSelect('STAV', 'Stav:', [ + 'ISSUED' => 'Vystaveno (Neuhrazeno)', + 'PAID' => 'Zaplaceno', + ])->setDefaultValue('ISSUED'); + + $form->addTextArea('CINNOSTI', 'Činnosti / Popis plnění:'); + + $form->addSubmit('save', 'Uložit fakturu') + ->setHtmlAttribute('class', 'btn btn-primary'); + + $form->onSuccess[] = [$this, 'fakturaFormSucceeded']; + + return $form; + } + + public function fakturaFormSucceeded(Form $form, \stdClass $data): void + { + $firmaId = $this->getUser()->getIdentity()->firma_id; + $httpRequest = $this->getHttpRequest(); + + // 1. Uložení hlavičky faktury + $fakturaId = $this->fakturaFacade->saveFaktura((array) $data, $this->id, $firmaId); + + // 2. Získání položek z POSTu (z dynamického formuláře z Latte/JS) + $polozkyRaw = $httpRequest->getPost('polozky', []); + + // 3. Uložení položek s automatickým výpočtem PORADI a celkové ceny + $this->fakturaFacade->savePolozky($fakturaId, $polozkyRaw, $firmaId); + + $this->flashMessage( + $this->id ? 'Faktura byla úspěšně upravena.' : 'Nová faktura byla vystavena.', + 'success' + ); + + $this->redirect('Faktura:default'); + } +} \ No newline at end of file diff --git a/app/UI/FakturaEdit/edit.latte b/app/UI/FakturaEdit/edit.latte new file mode 100644 index 0000000..faa4a12 --- /dev/null +++ b/app/UI/FakturaEdit/edit.latte @@ -0,0 +1,217 @@ +{block content} + + + + +
+
+
+

+ + {if $isEdit}Úprava faktury{else}Nová faktura{/if} +

+ + Zpět na seznam + +
+ +
+
+
+
    +
  • {$error}
  • +
+
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
Položky faktury
+ +
+ + + + + + + + + + + + + + + {foreach $polozky as $index => $p} + + + + + + + + + + + {/foreach} + + + + + + + + +
Název položky / službyPočetJedn.Cena/Jedn.Sleva %Celkem
+ + {$p->CENA|number:2, ',', ' '} CZK + +
Celková částka:0,00 CZK
+
+ + +
+ +
+ +
+ + +
+ +
+ + Zrušit +
+
+
+
+ +{syntax off} + +{/syntax} \ No newline at end of file diff --git a/sql/KufrikDB.sql b/sql/KufrikDB.sql index 6de670d..7c049b7 100644 --- a/sql/KufrikDB.sql +++ b/sql/KufrikDB.sql @@ -211,6 +211,7 @@ CREATE TABLE [dbo].[POLOZKA]( [ID] [int] IDENTITY(24,1) NOT NULL, [FAKTURA] [int] NOT NULL, [FIRMA_ID] [int] NOT NULL, -- Duplikováno pro rychlé filtry a RLS + [PORADI] [int] NOT NULL CONSTRAINT [DF_POLOZKA_PORADI] DEFAULT (1), -- Pořadí položky na faktuře [NAZEV] [nvarchar](255) NOT NULL, [POCET] [float] NOT NULL DEFAULT 1, @@ -225,7 +226,7 @@ CREATE TABLE [dbo].[POLOZKA]( ); GO -CREATE NONCLUSTERED INDEX [IX_POLOZKA_FAKTURA] ON [dbo].[POLOZKA]([FAKTURA] ASC); +CREATE NONCLUSTERED INDEX [IX_POLOZKA_FAKTURA_PORADI] ON [dbo].[POLOZKA]([FAKTURA] ASC, [PORADI] ASC); GO -- ==========================================