Nastavení - splatnost faktury, maska faktury

master
Kowalski 2026-10-17 09:42:43 +02:00
parent 28f30fa887
commit 4a4816dd0d
9 changed files with 367 additions and 24 deletions

View File

@ -2,6 +2,7 @@
namespace App\Model\Facade; namespace App\Model\Facade;
use App\Model\Faktura\FakturaCislovani;
use Nette\Database\Explorer; use Nette\Database\Explorer;
class FakturaFacade 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 return [
$maxAbs = $this->kufrikExplorer->query(" 'maska' => trim((string) ($firma->FAKTURA_MASKA ?? '')) ?: FakturaCislovani::MASKA_VYCHOZI,
SELECT MAX(CISLO_ABS) AS MAX_ABS 'obdobi' => FakturaCislovani::normalizeObdobi((string) ($firma->FAKTURA_CISLOVAT ?? '')),
FROM dbo.FAKTURA 'splatnostDny' => FakturaCislovani::normalizeSplatnost((int) ($firma->FAKTURA_SPLATNOST ?? FakturaCislovani::SPLATNOST_VYCHOZI)),
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 public function saveNastaveni(int $firmaId, string $maska, string $obdobi, int $splatnostDny): void
$dalsiCislo = ($maxAbs !== null) ? ((int) $maxAbs + 1) : 1; {
$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, 'FIRMA_ID' => $firmaId,
'ZAKAZNIK' => $zakaznikId, 'ZAKAZNIK' => $zakaznikId,
'CISLO' => $data['CISLO'], '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), 'FORMA_UHRADY' => (int) ($data['FORMA_UHRADY'] ?? 1),
'DATUM_VYSTAVENI' => $data['DATUM_VYSTAVENI'], 'DATUM_VYSTAVENI' => $data['DATUM_VYSTAVENI'],
'DATUM_SPLATNOSTI' => $data['DATUM_SPLATNOSTI'], '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 public function toggleUhrada(int $id): string
{ {
$faktura = $this->getById($id); $faktura = $this->getById($id);

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Model\Faktura;
/**
* Maska čísla faktury a období, po kterém se číselná řada nuluje.
*
* Značky v masce: {ROK}, {RR}, {MESIC}, {CISLO}, {CISLO4} (počet číslic 1–8).
*/
final class FakturaCislovani
{
public const MASKA_VYCHOZI = '{ROK}{CISLO4}';
public const OBDOBI_VYCHOZI = 'ROK';
public const SPLATNOST_VYCHOZI = 15;
public const OBDOBI = [
'ROK' => '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
);
}
}

View File

@ -52,7 +52,7 @@
<!-- MOJE OSVČ --> <!-- MOJE OSVČ -->
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button n:class="nav-link, $presenter->isLinkCurrent('Firma:*') ? active" <button n:class="nav-link, $presenter->isLinkCurrent('Firma:*') || $presenter->isLinkCurrent('Nastaveni:*') ? active"
id="cat-profile-tab" data-bs-toggle="tab" data-bs-target="#toolbar-profile" type="button" role="tab"> id="cat-profile-tab" data-bs-toggle="tab" data-bs-target="#toolbar-profile" type="button" role="tab">
<i class="bi bi-building me-1"></i> Moje OSVČ <i class="bi bi-building me-1"></i> Moje OSVČ
</button> </button>
@ -193,16 +193,16 @@
</div> </div>
<!-- MOJE OSVČ --> <!-- MOJE OSVČ -->
<div n:class="tab-pane, fade, $presenter->isLinkCurrent('Firma:*') ? 'show active'" id="toolbar-profile" role="tabpanel"> <div n:class="tab-pane, fade, $presenter->isLinkCurrent('Firma:*') || $presenter->isLinkCurrent('Nastaveni:*') ? 'show active'" id="toolbar-profile" role="tabpanel">
<div class="action-buttons"> <div class="action-buttons">
<a n:href="Firma:edit" n:class="btn, btn-action, $presenter->isLinkCurrent('Firma:edit') ? 'btn-primary active' : 'btn-outline-primary'"> <a n:href="Firma:edit" n:class="btn, btn-action, $presenter->isLinkCurrent('Firma:edit') ? 'btn-primary active' : 'btn-outline-primary'">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
<span>Upravit moje OSVČ</span> <span>Upravit moje OSVČ</span>
</a> </a>
<button type="button" class="btn btn-outline-secondary btn-action"> <a n:href="Nastaveni:default" n:class="btn, btn-action, $presenter->isLinkCurrent('Nastaveni:*') ? 'btn-primary active' : 'btn-outline-secondary'">
<i class="bi bi-gear-fill"></i> <i class="bi bi-gear-fill"></i>
<span>Nastavení</span> <span>Nastavení</span>
</button> </a>
</div> </div>
</div> </div>

View File

@ -25,11 +25,14 @@ class FakturaEditPresenter extends BasePresenter
$this->setView('edit'); $this->setView('edit');
$firmaId = $this->getUser()->getIdentity()->firma_id; $firmaId = $this->getUser()->getIdentity()->firma_id;
$noveCislo = $this->fakturaFacade->getNextCislo($firmaId); $dalsi = $this->fakturaFacade->getNextCislo($firmaId);
// Předplníme nové číslo faktury do formuláře
$this['fakturaForm']->setDefaults([ $this['fakturaForm']->setDefaults([
'CISLO' => $noveCislo, 'CISLO' => $dalsi['cislo'],
'CISLO_ABS' => $dalsi['cisloAbs'],
'DATUM_SPLATNOSTI' => (new \DateTimeImmutable('today'))
->modify('+' . $dalsi['splatnostDny'] . ' days')
->format('Y-m-d'),
]); ]);
} }
@ -111,15 +114,23 @@ class FakturaEditPresenter extends BasePresenter
->setRequired('Vyberte odběratele.'); ->setRequired('Vyberte odběratele.');
$form->addText('CISLO', 'Číslo faktury:') $form->addText('CISLO', 'Číslo faktury:')
->setDefaultValue(date('Y') . '001'); ->setDefaultValue(date('Y') . '0001');
$form->addHidden('CISLO_ABS');
$form->addText('DATUM_VYSTAVENI', 'Datum vystavení:') $form->addText('DATUM_VYSTAVENI', 'Datum vystavení:')
->setHtmlType('date') ->setHtmlType('date')
->setDefaultValue(date('Y-m-d')); ->setDefaultValue(date('Y-m-d'));
$splatnostDny = 15;
$firmaId = $this->getUser()->getIdentity()->firma_id ?? null;
if ($firmaId) {
$splatnostDny = $this->fakturaFacade->getNastaveni((int) $firmaId)['splatnostDny'];
}
$form->addText('DATUM_SPLATNOSTI', 'Datum splatnosti:') $form->addText('DATUM_SPLATNOSTI', 'Datum splatnosti:')
->setHtmlType('date') ->setHtmlType('date')
->setDefaultValue(date('Y-m-d', strtotime('+14 days'))) ->setDefaultValue((new \DateTimeImmutable('today'))->modify('+' . $splatnostDny . ' days')->format('Y-m-d'))
->setRequired('Zadejte datum splatnosti.'); ->setRequired('Zadejte datum splatnosti.');
$form->addText('DATUM_DUZP', 'Datum UZP:') $form->addText('DATUM_DUZP', 'Datum UZP:')

View File

@ -32,6 +32,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label n:name="CISLO" class="form-label fw-bold">Číslo faktury *</label> <label n:name="CISLO" class="form-label fw-bold">Číslo faktury *</label>
<input n:name="CISLO" class="form-control"> <input n:name="CISLO" class="form-control">
<input n:name="CISLO_ABS">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">

View File

@ -0,0 +1,82 @@
<?php
namespace App\UI\Nastaveni;
use App\Model\Faktura\FakturaCislovani;
use App\Model\Facade\FakturaFacade;
use App\UI\BasePresenter;
use Nette\Application\UI\Form;
class NastaveniPresenter extends BasePresenter
{
public function __construct(
private FakturaFacade $fakturaFacade
) {
parent::__construct();
}
public function actionDefault(): void
{
$firmaId = $this->getUser()->getIdentity()->firma_id ?? null;
if (!$firmaId) {
$this->flashMessage('K účtu není přiřazena žádná aktivní firma.', 'danger');
$this->redirect('Home:');
}
$nastaveni = $this->fakturaFacade->getNastaveni((int) $firmaId);
$this['nastaveniForm']->setDefaults([
'maska' => $nastaveni['maska'],
'obdobi' => $nastaveni['obdobi'],
'splatnost' => $nastaveni['splatnostDny'],
]);
}
public function renderDefault(): void
{
$firmaId = (int) $this->getUser()->getIdentity()->firma_id;
$dalsi = $this->fakturaFacade->getNextCislo($firmaId);
$this->template->prikladCisla = $dalsi['cislo'];
}
protected function createComponentNastaveniForm(): Form
{
$form = new Form;
$form->addText('maska', 'Číslování faktur (maska):')
->setRequired('Zadejte masku čísla faktury.')
->setMaxLength(40);
$form->addSelect('obdobi', 'Číslovat po:', FakturaCislovani::OBDOBI)
->setRequired('Vyberte období číslování.');
$form->addInteger('splatnost', 'Splatnost faktury (dny):')
->setRequired('Zadejte počet dní splatnosti.')
->setDefaultValue(FakturaCislovani::SPLATNOST_VYCHOZI)
->addRule(Form::Range, 'Splatnost musí být 0 až 365 dní.', [0, 365]);
$form->addSubmit('save', 'Uložit nastavení');
$form->onSuccess[] = [$this, 'nastaveniFormSucceeded'];
return $form;
}
public function nastaveniFormSucceeded(Form $form, \stdClass $data): void
{
$chyba = FakturaCislovani::validateMaska((string) $data->maska);
if ($chyba !== null) {
$form['maska']->addError($chyba);
return;
}
$firmaId = (int) $this->getUser()->getIdentity()->firma_id;
$this->fakturaFacade->saveNastaveni(
$firmaId,
(string) $data->maska,
(string) $data->obdobi,
(int) $data->splatnost
);
$this->flashMessage('Nastavení faktur bylo uloženo.', 'success');
$this->redirect('this');
}
}

View File

@ -0,0 +1,59 @@
{block content}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-10 col-lg-7">
<div class="content-card my-3">
<h2 class="h4 mb-3" style="color: var(--header-bg);">
<i class="bi bi-gear-fill me-2"></i>Nastavení
</h2>
<form n:name="nastaveniForm" class="login-form">
<div n:if="$form->errors" class="alert alert-danger mb-3">
<ul class="mb-0 ps-3">
<li n:foreach="$form->errors as $error">{$error}</li>
</ul>
</div>
<fieldset class="border rounded p-3">
<legend class="float-none w-auto px-2 fs-6 fw-bold mb-0">Faktury</legend>
<div class="row align-items-center mb-3">
<label n:name="maska" class="col-sm-5 col-form-label">Číslování faktur (maska):</label>
<div class="col-sm-7">
<input n:name="maska" class="form-control">
</div>
</div>
<div class="row align-items-center mb-3">
<label n:name="obdobi" class="col-sm-5 col-form-label">Číslovat po:</label>
<div class="col-sm-7">
<select n:name="obdobi" class="form-select"></select>
</div>
</div>
<div class="row align-items-center">
<label n:name="splatnost" class="col-sm-5 col-form-label">Splatnost faktury (dny):</label>
<div class="col-sm-4">
<input n:name="splatnost" class="form-control" style="max-width: 8rem;">
</div>
</div>
<p class="text-muted small mt-3 mb-0">
Značky v masce: <code>{='{ROK}'}</code> rok, <code>{='{RR}'}</code> rok na dvě číslice,
<code>{='{MESIC}'}</code> měsíc, <code>{='{CISLO4}'}</code> pořadové číslo na čtyři místa.
Číselná řada se nuluje podle volby „Číslovat po“.
Příští faktura: <strong>{$prikladCisla}</strong>.
</p>
</fieldset>
<div class="d-flex justify-content-end mt-4">
<button n:name="save" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i> Uložit nastavení
</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -136,6 +136,11 @@ CREATE TABLE [dbo].[FIRMA] (
[IBAN] [nvarchar](34) NULL, [IBAN] [nvarchar](34) NULL,
[BIC] [nvarchar](11) NULL, [BIC] [nvarchar](11) NULL,
-- Číslování a splatnost nových faktur
[FAKTURA_MASKA] [nvarchar](40) NOT NULL CONSTRAINT [DF_FIRMA_FAKTURA_MASKA] DEFAULT (N'{ROK}{CISLO4}'),
[FAKTURA_CISLOVAT] [nvarchar](12) NOT NULL CONSTRAINT [DF_FIRMA_FAKTURA_CISLOVAT] DEFAULT (N'ROK'),
[FAKTURA_SPLATNOST] [int] NOT NULL CONSTRAINT [DF_FIRMA_FAKTURA_SPLATNOST] DEFAULT (15),
CONSTRAINT [PK_FIRMA] PRIMARY KEY CLUSTERED ([ID] ASC) CONSTRAINT [PK_FIRMA] PRIMARY KEY CLUSTERED ([ID] ASC)
); );
@ -173,7 +178,7 @@ CREATE TABLE [dbo].[FAKTURA](
[ZAKAZNIK] [int] NULL, -- Vazba na adresář (volitelná, pro historii) [ZAKAZNIK] [int] NULL, -- Vazba na adresář (volitelná, pro historii)
[CISLO] [nvarchar](20) NOT NULL, [CISLO] [nvarchar](20) NOT NULL,
[CISLO_ABS] [int] NOT NULL, -- Sekvenční pořadové číslo v roce [CISLO_ABS] [int] NOT NULL, -- Sekvenční pořadové číslo v rámci období (rok / měsíc / nepřetržitě)
[FORMA_UHRADY] [tinyint] NOT NULL CONSTRAINT [DF_FAKTURA_FORMA_UHRADY] DEFAULT ((1)), [FORMA_UHRADY] [tinyint] NOT NULL CONSTRAINT [DF_FAKTURA_FORMA_UHRADY] DEFAULT ((1)),
[DATUM_VYSTAVENI] [date] NOT NULL, [DATUM_VYSTAVENI] [date] NOT NULL,

View File

@ -0,0 +1,23 @@
-- Nastavení číslování a splatnosti faktur na tabulce FIRMA.
-- Spouštět v každé klientské databázi (např. kufrik).
IF COL_LENGTH('dbo.FIRMA', 'FAKTURA_MASKA') IS NULL
BEGIN
ALTER TABLE dbo.FIRMA ADD [FAKTURA_MASKA] [nvarchar](40) NOT NULL
CONSTRAINT [DF_FIRMA_FAKTURA_MASKA] DEFAULT (N'{ROK}{CISLO4}');
END
GO
IF COL_LENGTH('dbo.FIRMA', 'FAKTURA_CISLOVAT') IS NULL
BEGIN
ALTER TABLE dbo.FIRMA ADD [FAKTURA_CISLOVAT] [nvarchar](12) NOT NULL
CONSTRAINT [DF_FIRMA_FAKTURA_CISLOVAT] DEFAULT (N'ROK');
END
GO
IF COL_LENGTH('dbo.FIRMA', 'FAKTURA_SPLATNOST') IS NULL
BEGIN
ALTER TABLE dbo.FIRMA ADD [FAKTURA_SPLATNOST] [int] NOT NULL
CONSTRAINT [DF_FIRMA_FAKTURA_SPLATNOST] DEFAULT (15);
END
GO