Kufrik/app/Model/Facade/FakturaFacade.php

498 lines
17 KiB
PHP

<?php
namespace App\Model\Facade;
use App\Model\Faktura\FakturaCislovani;
use Nette\Database\Explorer;
class FakturaFacade
{
public const STAV_VYSTAVENO = 'ISSUED';
public const STAV_CASTECNE = 'PARTIAL';
public const STAV_ZAPLACENO = 'PAID';
public function __construct(
private Explorer $kufrikExplorer
) {
}
/**
* Stav úhrady podle součtu plateb vůči částce faktury.
*/
public static function stavZCastek(float $celkem, float $zaplaceno): string
{
if ($zaplaceno <= 0.004) {
return self::STAV_VYSTAVENO;
}
if ($celkem > 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();
}
/**
* 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();
}
}