From 4a4816dd0d78a148624c94f8c74ca7e114975e57 Mon Sep 17 00:00:00 2001 From: Kowalski Date: Sat, 17 Oct 2026 09:42:43 +0200 Subject: [PATCH] =?UTF-8?q?Nastaven=C3=AD=20-=20splatnost=20faktury,=20mas?= =?UTF-8?q?ka=20faktury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Model/Facade/FakturaFacade.php | 101 +++++++++++++++++--- app/Model/Faktura/FakturaCislovani.php | 89 +++++++++++++++++ app/UI/@layout.latte | 8 +- app/UI/FakturaEdit/FakturaEditPresenter.php | 21 +++- app/UI/FakturaEdit/edit.latte | 1 + app/UI/Nastaveni/NastaveniPresenter.php | 82 ++++++++++++++++ app/UI/Nastaveni/default.latte | 59 ++++++++++++ sql/KufrikDB.sql | 7 +- sql/add_firma_faktura_nastaveni.sql | 23 +++++ 9 files changed, 367 insertions(+), 24 deletions(-) create mode 100644 app/Model/Faktura/FakturaCislovani.php create mode 100644 app/UI/Nastaveni/NastaveniPresenter.php create mode 100644 app/UI/Nastaveni/default.latte create mode 100644 sql/add_firma_faktura_nastaveni.sql diff --git a/app/Model/Facade/FakturaFacade.php b/app/Model/Facade/FakturaFacade.php index ea68392..9096051 100644 --- a/app/Model/Facade/FakturaFacade.php +++ b/app/Model/Facade/FakturaFacade.php @@ -2,6 +2,7 @@ namespace App\Model\Facade; +use App\Model\Faktura\FakturaCislovani; use Nette\Database\Explorer; class FakturaFacade @@ -95,24 +96,79 @@ class FakturaFacade } /** - * Vygeneruje následující číslo faktury pro aktuální rok a firmu + * Maska, období číslování a výchozí splatnost uložené u firmy. + * + * @return array{maska: string, obdobi: string, splatnostDny: int} */ - public function getNextCislo(int $firmaId): string + public function getNastaveni(int $firmaId): array { - $aktualniRok = (int) date('Y'); + $firma = $this->kufrikExplorer->table('FIRMA')->get($firmaId); - // 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; + return [ + 'maska' => trim((string) ($firma->FAKTURA_MASKA ?? '')) ?: FakturaCislovani::MASKA_VYCHOZI, + 'obdobi' => FakturaCislovani::normalizeObdobi((string) ($firma->FAKTURA_CISLOVAT ?? '')), + 'splatnostDny' => FakturaCislovani::normalizeSplatnost((int) ($firma->FAKTURA_SPLATNOST ?? FakturaCislovani::SPLATNOST_VYCHOZI)), + ]; + } - // Pokud ještě v tomto roce žádná faktura neexistuje, začínáme od 1 - $dalsiCislo = ($maxAbs !== null) ? ((int) $maxAbs + 1) : 1; + public function saveNastaveni(int $firmaId, string $maska, string $obdobi, int $splatnostDny): void + { + $this->kufrikExplorer->table('FIRMA')->where('ID', $firmaId)->update([ + 'FAKTURA_MASKA' => trim($maska), + 'FAKTURA_CISLOVAT' => FakturaCislovani::normalizeObdobi($obdobi), + 'FAKTURA_SPLATNOST' => FakturaCislovani::normalizeSplatnost($splatnostDny), + ]); + } - // 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); + /** + * Další číslo faktury podle masky a období číslování. + * + * @return array{cislo: string, cisloAbs: int, splatnostDny: int} + */ + public function getNextCislo(int $firmaId, ?\DateTimeInterface $datum = null): array + { + $nastaveni = $this->getNastaveni($firmaId); + $datum ??= new \DateTimeImmutable('today'); + $rok = (int) $datum->format('Y'); + $mesic = (int) $datum->format('n'); + + $where = ['FIRMA_ID = ?']; + $params = [$firmaId]; + if ($nastaveni['obdobi'] === 'ROK') { + $where[] = 'YEAR(DATUM_VYSTAVENI) = ?'; + $params[] = $rok; + } elseif ($nastaveni['obdobi'] === 'MESIC') { + $where[] = 'YEAR(DATUM_VYSTAVENI) = ?'; + $where[] = 'MONTH(DATUM_VYSTAVENI) = ?'; + $params[] = $rok; + $params[] = $mesic; + } + + $maxAbs = $this->kufrikExplorer->query( + 'SELECT MAX(CISLO_ABS) AS MAX_ABS FROM dbo.FAKTURA WHERE ' . implode(' AND ', $where), + ...$params + )->fetch()?->MAX_ABS; + + $dalsi = ($maxAbs !== null) ? ((int) $maxAbs + 1) : 1; + + for ($i = 0; $i < 10000; $i++) { + $cislo = FakturaCislovani::format($nastaveni['maska'], $rok, $mesic, $dalsi); + $obsazeno = $this->kufrikExplorer->query( + 'SELECT 1 AS X FROM dbo.FAKTURA WHERE FIRMA_ID = ? AND CISLO = ?', + $firmaId, + $cislo + )->fetch(); + if (!$obsazeno) { + return [ + 'cislo' => $cislo, + 'cisloAbs' => $dalsi, + 'splatnostDny' => $nastaveni['splatnostDny'], + ]; + } + $dalsi++; + } + + throw new \RuntimeException('Nepodařilo se najít volné číslo faktury.'); } /** @@ -157,7 +213,7 @@ class FakturaFacade 'FIRMA_ID' => $firmaId, 'ZAKAZNIK' => $zakaznikId, 'CISLO' => $data['CISLO'], - 'CISLO_ABS' => (int) ($data['CISLO_ABS'] ?? substr($data['CISLO'], -3)), + 'CISLO_ABS' => $this->resolveCisloAbs($data, $firmaId), 'FORMA_UHRADY' => (int) ($data['FORMA_UHRADY'] ?? 1), 'DATUM_VYSTAVENI' => $data['DATUM_VYSTAVENI'], 'DATUM_SPLATNOSTI' => $data['DATUM_SPLATNOSTI'], @@ -223,6 +279,23 @@ class FakturaFacade } } + /** + * Pořadové číslo řady. Když formulář nepřinesl platné CISLO_ABS, dopočítá se z nastavení. + */ + private function resolveCisloAbs(array $data, int $firmaId): int + { + $abs = (int) ($data['CISLO_ABS'] ?? 0); + if ($abs > 0) { + return $abs; + } + + $datum = !empty($data['DATUM_VYSTAVENI']) + ? new \DateTimeImmutable((string) $data['DATUM_VYSTAVENI']) + : new \DateTimeImmutable('today'); + + return $this->getNextCislo($firmaId, $datum)['cisloAbs']; + } + public function toggleUhrada(int $id): string { $faktura = $this->getById($id); diff --git a/app/Model/Faktura/FakturaCislovani.php b/app/Model/Faktura/FakturaCislovani.php new file mode 100644 index 0000000..fbf67cc --- /dev/null +++ b/app/Model/Faktura/FakturaCislovani.php @@ -0,0 +1,89 @@ + 'po roce', + 'MESIC' => 'po měsíci', + 'NEPRETRZITE' => 'nepřetržitě', + ]; + + public static function normalizeObdobi(string $obdobi): string + { + $obdobi = strtoupper(trim($obdobi)); + return isset(self::OBDOBI[$obdobi]) ? $obdobi : self::OBDOBI_VYCHOZI; + } + + public static function normalizeSplatnost(int $dny): int + { + return max(0, min(365, $dny)); + } + + /** + * Maska musí obsahovat právě značku pořadového čísla a vejít se do čísla faktury (20 znaků). + */ + public static function validateMaska(string $maska): ?string + { + $maska = trim($maska); + if ($maska === '') { + return 'Zadejte masku čísla faktury.'; + } + if (mb_strlen($maska) > 40) { + return 'Maska může mít nejvýše 40 znaků.'; + } + if (!preg_match('/^(?:[^{}]|\{(?:ROK|RRRR|RR|MESIC|MM|CISLO\d?)\})+$/u', $maska)) { + return 'Maska obsahuje neznámou značku. Povolené jsou {ROK}, {RR}, {MESIC} a {CISLO} nebo {CISLO4}.'; + } + if (!preg_match('/\{CISLO\d?\}/', $maska)) { + return 'Maska musí obsahovat značku pořadového čísla, například {CISLO4}.'; + } + if (preg_match_all('/\{CISLO(\d)?\}/', $maska, $shody) && count($shody[0]) > 1) { + return 'Maska může obsahovat značku pořadového čísla jen jednou.'; + } + if (isset($shody[1][0]) && $shody[1][0] !== '' && ((int) $shody[1][0] < 1 || (int) $shody[1][0] > 8)) { + return 'Počet číslic u {CISLO} musí být 1 až 8, například {CISLO4}.'; + } + + $priklad = self::format($maska, 2026, 1, 1); + if (mb_strlen($priklad) > 20) { + return 'Podle této masky by číslo faktury přesáhlo 20 znaků.'; + } + + return null; + } + + public static function format(string $maska, int $rok, int $mesic, int $cislo): string + { + $mesicText = str_pad((string) $mesic, 2, '0', STR_PAD_LEFT); + $vysledek = strtr($maska, [ + '{ROK}' => (string) $rok, + '{RRRR}' => (string) $rok, + '{RR}' => substr((string) $rok, -2), + '{MESIC}' => $mesicText, + '{MM}' => $mesicText, + ]); + + return (string) preg_replace_callback( + '/\{CISLO(\d)?\}/', + static function (array $shoda) use ($cislo): string { + $text = (string) $cislo; + $sirka = ($shoda[1] ?? '') !== '' ? (int) $shoda[1] : 0; + return $sirka > 0 ? str_pad($text, $sirka, '0', STR_PAD_LEFT) : $text; + }, + $vysledek + ); + } +} diff --git a/app/UI/@layout.latte b/app/UI/@layout.latte index 2bac57e..f2ff34a 100644 --- a/app/UI/@layout.latte +++ b/app/UI/@layout.latte @@ -52,7 +52,7 @@