0 && $zaplaceno + 0.004 >= $celkem) { return self::STAV_ZAPLACENO; } return self::STAV_CASTECNE; } public static function stavPopis(string $stav): string { return match ($stav) { self::STAV_ZAPLACENO => 'Zaplaceno', self::STAV_CASTECNE => 'Částečně uhrazeno', default => 'Neuhrazeno', }; } /** * Vrátí seznam faktur s dopočítanou celkovou částkou z položek. */ public function getFiltered(?int $rok = null, string $mesic = ''): array { $where = []; $params = []; if ($rok) { $where[] = 'YEAR(F.DATUM_VYSTAVENI) = ?'; $params[] = $rok; } $mesicCislo = (int) $mesic; if ($mesic !== '' && $mesicCislo >= 1 && $mesicCislo <= 12) { $where[] = 'MONTH(F.DATUM_VYSTAVENI) = ?'; $params[] = $mesicCislo; } $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = " SELECT F.*, ISNULL((SELECT SUM(P.CENA) FROM dbo.POLOZKA P WHERE P.FAKTURA = F.ID), 0) AS CASTKA_CELKEM, ISNULL((SELECT SUM(PL.CASTKA) FROM dbo.PLATBA PL WHERE PL.FAKTURA = F.ID), 0) AS ZAPLACENO FROM dbo.FAKTURA F {$whereSql} ORDER BY F.DATUM_VYSTAVENI DESC, F.ID DESC "; return $this->kufrikExplorer->query($sql, ...$params)->fetchAll(); } /** * Roky, pro které existují faktury (plus aktuální / právě vybraný rok). * * @return int[] */ public function getDostupneRoky(?int $vybranyRok = null): array { $rows = $this->kufrikExplorer->query(" SELECT DISTINCT YEAR(DATUM_VYSTAVENI) AS ROK FROM dbo.FAKTURA WHERE DATUM_VYSTAVENI IS NOT NULL ORDER BY ROK DESC ")->fetchAll(); $roky = []; foreach ($rows as $row) { $roky[] = (int) $row->ROK; } foreach ([(int) date('Y'), $vybranyRok] as $rok) { if ($rok && !in_array($rok, $roky, true)) { $roky[] = $rok; } } rsort($roky, SORT_NUMERIC); return $roky; } /** * 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(); } /** * Názvy položek pro našeptávač. Jednotka a cena jsou z posledního použití, * pořadí je podle počtu výskytů na fakturách za poslední rok. * * @return list */ public function getKatalogPolozek(): array { $od = (new \DateTimeImmutable('today'))->modify('-1 year')->format('Y-m-d'); $rows = $this->kufrikExplorer->query(" SELECT katalog.NAZEV, katalog.JEDNOTKA, katalog.CENA_JEDNOTKA FROM ( SELECT P.NAZEV, P.JEDNOTKA, P.CENA_JEDNOTKA, ROW_NUMBER() OVER (PARTITION BY P.NAZEV ORDER BY P.ID DESC) AS rn FROM dbo.POLOZKA P WHERE P.NAZEV IS NOT NULL AND LTRIM(RTRIM(P.NAZEV)) <> '' ) AS katalog LEFT JOIN ( SELECT P.NAZEV, COUNT(*) AS POCET FROM dbo.POLOZKA P INNER JOIN dbo.FAKTURA F ON F.ID = P.FAKTURA WHERE F.DATUM_VYSTAVENI >= ? GROUP BY P.NAZEV ) AS pouziti ON pouziti.NAZEV = katalog.NAZEV WHERE katalog.rn = 1 ORDER BY ISNULL(pouziti.POCET, 0) DESC, katalog.NAZEV ASC ", $od)->fetchAll(); $katalog = []; foreach ($rows as $row) { $katalog[] = [ 'nazev' => (string) $row->NAZEV, 'jednotka' => trim((string) ($row->JEDNOTKA ?? '')), 'cena' => round((float) $row->CENA_JEDNOTKA, 2), ]; } return $katalog; } /** * Maska, období číslování a výchozí splatnost uložené u firmy. * * @return array{maska: string, obdobi: string, splatnostDny: int} */ public function getNastaveni(int $firmaId): array { $firma = $this->kufrikExplorer->table('FIRMA')->get($firmaId); 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)), ]; } 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), ]); } /** * 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.'); } /** * 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, '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; if (!$zakaznikId || !$zakaznik) { throw new \InvalidArgumentException('Faktura musí mít odběratele.'); } $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' => $this->resolveCisloAbs($data, $firmaId), '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, ]); } } /** * 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']; } /** * Faktury dřív označené jen stavem Zaplaceno dostanou jednu platbu, * a stav všech faktur se srovná se součtem plateb. */ public function reconcileUhrady(): void { $this->kufrikExplorer->query(" INSERT INTO dbo.PLATBA (FAKTURA, CASTKA, DATUM, POZNAMKA) SELECT F.ID, ISNULL((SELECT SUM(P.CENA) FROM dbo.POLOZKA P WHERE P.FAKTURA = F.ID), 0), CAST(ISNULL(F.DATUM_SPLATNOSTI, F.DATUM_VYSTAVENI) AS datetime2), N'Doplněno podle původního stavu Zaplaceno' FROM dbo.FAKTURA F WHERE F.STAV = 'PAID' AND NOT EXISTS (SELECT 1 FROM dbo.PLATBA PL WHERE PL.FAKTURA = F.ID) AND ISNULL((SELECT SUM(P.CENA) FROM dbo.POLOZKA P WHERE P.FAKTURA = F.ID), 0) > 0 "); $this->kufrikExplorer->query(" UPDATE F SET STAV = CASE WHEN CAST(ISNULL(U.ZAPLACENO, 0) AS decimal(19,4)) <= 0 THEN 'ISSUED' WHEN CAST(ISNULL(C.CELKEM, 0) AS decimal(19,4)) > 0 AND CAST(ISNULL(U.ZAPLACENO, 0) AS decimal(19,4)) + 0.004 >= CAST(ISNULL(C.CELKEM, 0) AS decimal(19,4)) THEN 'PAID' ELSE 'PARTIAL' END FROM dbo.FAKTURA F LEFT JOIN ( SELECT FAKTURA, SUM(CASTKA) AS ZAPLACENO FROM dbo.PLATBA GROUP BY FAKTURA ) U ON U.FAKTURA = F.ID LEFT JOIN ( SELECT FAKTURA, SUM(CENA) AS CELKEM FROM dbo.POLOZKA GROUP BY FAKTURA ) C ON C.FAKTURA = F.ID WHERE F.STAV <> CASE WHEN CAST(ISNULL(U.ZAPLACENO, 0) AS decimal(19,4)) <= 0 THEN 'ISSUED' WHEN CAST(ISNULL(C.CELKEM, 0) AS decimal(19,4)) > 0 AND CAST(ISNULL(U.ZAPLACENO, 0) AS decimal(19,4)) + 0.004 >= CAST(ISNULL(C.CELKEM, 0) AS decimal(19,4)) THEN 'PAID' ELSE 'PARTIAL' END "); } public function getPlatby(int $fakturaId): array { return $this->kufrikExplorer->query(' SELECT PL.* FROM dbo.PLATBA PL INNER JOIN dbo.FAKTURA F ON F.ID = PL.FAKTURA WHERE PL.FAKTURA = ? ORDER BY PL.DATUM, PL.ID ', $fakturaId)->fetchAll(); } public function sumaPlateb(int $fakturaId): float { $row = $this->kufrikExplorer->query(' SELECT ISNULL(SUM(PL.CASTKA), 0) AS ZAPLACENO FROM dbo.PLATBA PL INNER JOIN dbo.FAKTURA F ON F.ID = PL.FAKTURA WHERE PL.FAKTURA = ? ', $fakturaId)->fetch(); return (float) ($row->ZAPLACENO ?? 0); } /** * @return string Nový stav faktury */ public function addPlatba(int $fakturaId, float $castka, \DateTimeInterface $datum, ?string $poznamka): string { if (!$this->getById($fakturaId)) { throw new \InvalidArgumentException('Faktura nebyla nalezena.'); } if ($castka <= 0) { throw new \InvalidArgumentException('Částka platby musí být větší než 0.'); } $poznamka = trim((string) $poznamka); if (mb_strlen($poznamka) > 2000) { $poznamka = mb_substr($poznamka, 0, 2000); } $this->kufrikExplorer->table('PLATBA')->insert([ 'FAKTURA' => $fakturaId, 'CASTKA' => round($castka, 2), 'DATUM' => $datum->format('Y-m-d'), 'POZNAMKA' => $poznamka !== '' ? $poznamka : null, ]); return $this->syncStav($fakturaId); } public function updatePlatba(int $id, float $castka, \DateTimeInterface $datum, ?string $poznamka): ?int { $row = $this->kufrikExplorer->query(' SELECT PL.ID, PL.FAKTURA FROM dbo.PLATBA PL INNER JOIN dbo.FAKTURA F ON F.ID = PL.FAKTURA WHERE PL.ID = ? ', $id)->fetch(); if (!$row) { return null; } if ($castka <= 0) { throw new \InvalidArgumentException('Částka platby musí být větší než 0.'); } $poznamka = trim((string) $poznamka); if (mb_strlen($poznamka) > 2000) { $poznamka = mb_substr($poznamka, 0, 2000); } $this->kufrikExplorer->table('PLATBA')->where('ID', (int) $row->ID)->update([ 'CASTKA' => round($castka, 2), 'DATUM' => $datum->format('Y-m-d'), 'POZNAMKA' => $poznamka !== '' ? $poznamka : null, ]); $this->syncStav((int) $row->FAKTURA); return (int) $row->FAKTURA; } public function deletePlatba(int $id): ?int { $row = $this->kufrikExplorer->query(' SELECT PL.ID, PL.FAKTURA FROM dbo.PLATBA PL INNER JOIN dbo.FAKTURA F ON F.ID = PL.FAKTURA WHERE PL.ID = ? ', $id)->fetch(); if (!$row) { return null; } $this->kufrikExplorer->table('PLATBA')->where('ID', (int) $row->ID)->delete(); $this->syncStav((int) $row->FAKTURA); return (int) $row->FAKTURA; } public function syncStav(int $fakturaId): string { $faktura = $this->getById($fakturaId); if (!$faktura) { return ''; } $celkem = 0.0; foreach ($this->getPolozky($fakturaId) as $polozka) { $celkem += (float) $polozka->CENA; } $stav = self::stavZCastek($celkem, $this->sumaPlateb($fakturaId)); $this->kufrikExplorer->table('FAKTURA') ->where('ID', $fakturaId) ->update(['STAV' => $stav]); return $stav; } public function delete(int $id): void { $this->kufrikExplorer->table('FAKTURA')->where('ID', $id)->delete(); } /** * Kopie faktury: nové číslo, dnešní data, stejný odběratel a položky, bez plateb. * * @return int ID nové faktury */ public function duplicate(int $id, int $firmaId): int { $zdroj = $this->getById($id); if (!$zdroj) { throw new \InvalidArgumentException('Faktura nebyla nalezena.'); } $dnes = new \DateTimeImmutable('today'); $dalsi = $this->getNextCislo($firmaId, $dnes); $datumVystaveni = $dnes->format('Y-m-d'); $datumSplatnosti = $dnes ->modify('+' . $dalsi['splatnostDny'] . ' days') ->format('Y-m-d'); $novaId = $this->saveFaktura([ 'ZAKAZNIK_ID' => (int) $zdroj->ZAKAZNIK, 'CISLO' => $dalsi['cislo'], 'CISLO_ABS' => $dalsi['cisloAbs'], 'FORMA_UHRADY' => (int) ($zdroj->FORMA_UHRADY ?? 1), 'DATUM_VYSTAVENI' => $datumVystaveni, 'DATUM_SPLATNOSTI' => $datumSplatnosti, 'DATUM_DUZP' => $datumVystaveni, 'STAV' => self::STAV_VYSTAVENO, 'CINNOSTI' => $zdroj->CINNOSTI, ], null, $firmaId); $polozky = []; foreach ($this->getPolozky($id) as $polozka) { $polozky[] = [ 'NAZEV' => $polozka->NAZEV, 'POCET' => $polozka->POCET, 'JEDNOTKA' => $polozka->JEDNOTKA, 'CENA_JEDNOTKA' => $polozka->CENA_JEDNOTKA, 'SLEVA' => $polozka->SLEVA, ]; } $this->savePolozky($novaId, $polozky, $firmaId); $this->syncStav($novaId); return $novaId; } }