diff --git a/.gitignore b/.gitignore index bfea246..03db9fa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ # KOW temp/ log/ +data/sdf/* +!data/sdf/.gitkeep #ZAZA diff --git a/app/Model/Database/KufrikConnectionFactory.php b/app/Model/Database/KufrikConnectionFactory.php index 9813bdf..2c9f84f 100644 --- a/app/Model/Database/KufrikConnectionFactory.php +++ b/app/Model/Database/KufrikConnectionFactory.php @@ -29,7 +29,6 @@ class KufrikConnectionFactory $identity = $this->user->getIdentity(); - // Server, Databáze i FirmaId se načtou z Identity přihlášeného uživatele $host = $identity->server ?? null; $databaseName = $identity->database ?? null; $firmaId = $identity->firma_id ?? null; @@ -38,30 +37,44 @@ class KufrikConnectionFactory throw new \LogicException('Přihlášený uživatel nemá v tabulce LOGIN definovaný SERVER, DATABASE nebo FIRMA_ID.'); } - // Dynamický DSN řetězec - $dsn = "sqlsrv:Server={$host};Database={$databaseName};LoginTimeout=3;"; + return $this->createTenantConnection((string) $host, (string) $databaseName, (int) $firmaId); + } + + public function createExplorer(): Explorer + { + return $this->createExplorerFromConnection($this->createConnection()); + } + + public function createTenantExplorer(string $host, string $databaseName, int $firmaId): Explorer + { + return $this->createExplorerFromConnection( + $this->createTenantConnection($host, $databaseName, $firmaId) + ); + } + + private function createTenantConnection(string $host, string $databaseName, int $firmaId): Connection + { + $dsn = "sqlsrv:Server={$host};Database={$databaseName};LoginTimeout=10;"; $connection = new Connection( $dsn, $this->dbUser, $this->dbPassword, [ - 'lazy' => false, // Spojení musí vzniknout ihned + 'lazy' => false, 'driverOptions' => [ \PDO::SQLSRV_ATTR_ENCODING => \PDO::SQLSRV_ENCODING_UTF8, ] ] ); - // NASTAVENÍ RLS KONTEXTU PRO MS SQL SERVER - $connection->query('EXEC sp_set_session_context @key = N\'FirmaId\', @value = ?', (int)$firmaId); + $connection->query('EXEC sp_set_session_context @key = N\'FirmaId\', @value = ?', $firmaId); return $connection; } - public function createExplorer(): Explorer + private function createExplorerFromConnection(Connection $connection): Explorer { - $connection = $this->createConnection(); $storage = new MemoryStorage(); $structure = new Structure($connection, $storage); diff --git a/app/Model/Facade/DatabazeFacade.php b/app/Model/Facade/DatabazeFacade.php index 6d5a47f..982a63b 100644 --- a/app/Model/Facade/DatabazeFacade.php +++ b/app/Model/Facade/DatabazeFacade.php @@ -3,25 +3,201 @@ namespace App\Model\Facade; use Nette\Database\Connection; +use Nette\Http\FileUpload; +use Nette\Utils\FileSystem; +use Nette\Utils\Json; class DatabazeFacade { public function __construct( - private Connection $masterDb + private Connection $masterDb, + private string $sdfStorageDir ) { } /** * Seznam databází z master tabulky LOGIN (jeden login = jedna databáze). * - * @return \Nette\Database\Row[] + * @return \stdClass[] */ public function getAll(): array { - return $this->masterDb->fetchAll( + $rows = $this->masterDb->fetchAll( 'SELECT ID, EMAIL, POSLEDNI_PRIHLASENI, AKTIVNI, [DATABASE], [SERVER], FIRMA_ID, IS_ADMIN FROM dbo.LOGIN ORDER BY [DATABASE], EMAIL' ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + + return $result; + } + + public function getById(int $id): ?\stdClass + { + $row = $this->masterDb->fetch( + 'SELECT ID, EMAIL, POSLEDNI_PRIHLASENI, AKTIVNI, [DATABASE], [SERVER], FIRMA_ID, IS_ADMIN + FROM dbo.LOGIN + WHERE ID = ?', + $id + ); + + return $row ? $this->hydrate($row) : null; + } + + public function getSdfFilePath(int $loginId): ?string + { + $path = $this->sdfPath($loginId); + return is_file($path) ? $path : null; + } + + /** + * @param array $stats + */ + public function markSdfImported(int $loginId, array $stats): void + { + $meta = $this->readSdfMeta($loginId); + $meta['importedAt'] = (new \DateTimeImmutable())->format('c'); + $meta['importStats'] = $stats; + FileSystem::createDir($this->sdfStorageDir); + FileSystem::write($this->sdfMetaPath($loginId), Json::encode($meta, pretty: true)); + } + + /** + * Položky selectu: ID => „databáze — e-mail“. + * + * @return array + */ + public function getSelectItems(): array + { + $items = []; + foreach ($this->getAll() as $login) { + $items[(int) $login->ID] = $login->DATABASE . ' — ' . $login->EMAIL; + } + + return $items; + } + + /** + * Uloží nahraný .sdf k loginu. + */ + public function saveSdf(int $loginId, FileUpload $file): void + { + $exists = $this->masterDb->fetchField('SELECT ID FROM dbo.LOGIN WHERE ID = ?', $loginId); + if (!$exists) { + throw new \InvalidArgumentException('Vybraná databáze (login) neexistuje.'); + } + + if (!$file->isOk()) { + throw new \RuntimeException('Soubor se nepodařilo nahrát. Zkontrolujte velikost (limit PHP/upload).'); + } + + $extension = strtolower(pathinfo($file->getUntrustedName(), PATHINFO_EXTENSION)); + if ($extension !== 'sdf') { + throw new \InvalidArgumentException('Soubor musí mít příponu .sdf.'); + } + + FileSystem::createDir($this->sdfStorageDir); + + $file->move($this->sdfPath($loginId)); + $this->clearSdfImportMeta($loginId); + + $meta = [ + 'originalName' => $this->safeOriginalName($file->getUntrustedName()), + 'uploadedAt' => (new \DateTimeImmutable())->format('c'), + 'size' => $file->getSize(), + 'loginId' => $loginId, + ]; + + FileSystem::write($this->sdfMetaPath($loginId), Json::encode($meta, pretty: true)); + } + + /** + * @return array{originalName?: string, uploadedAt?: string, size?: int}|array{} + */ + private function readSdfMeta(int $loginId): array + { + $sdf = $this->sdfPath($loginId); + if (!is_file($sdf)) { + return []; + } + + $metaFile = $this->sdfMetaPath($loginId); + $meta = []; + if (is_file($metaFile)) { + try { + $decoded = Json::decode(FileSystem::read($metaFile), forceArrays: true); + $meta = is_array($decoded) ? $decoded : []; + } catch (\Throwable) { + $meta = []; + } + } + + $meta['size'] = filesize($sdf) ?: ($meta['size'] ?? null); + $meta['uploadedAt'] ??= date('c', filemtime($sdf) ?: time()); + $meta['originalName'] ??= $loginId . '.sdf'; + + return $meta; + } + + private function hydrate(object $row): \stdClass + { + $item = (object) (array) $row; + $meta = $this->readSdfMeta((int) $item->ID); + $item->SDF_SOUBOR = $meta['originalName'] ?? null; + $item->SDF_NAHRANO = isset($meta['uploadedAt']) ? new \DateTimeImmutable($meta['uploadedAt']) : null; + $item->SDF_VELIKOST = $meta['size'] ?? null; + $item->SDF_VELIKOST_TEXT = isset($meta['size']) ? $this->formatBytes((int) $meta['size']) : null; + $item->SDF_IMPORTOVANO = isset($meta['importedAt']) ? new \DateTimeImmutable($meta['importedAt']) : null; + return $item; + } + + private function clearSdfImportMeta(int $loginId): void + { + $metaFile = $this->sdfMetaPath($loginId); + if (!is_file($metaFile)) { + return; + } + try { + $decoded = Json::decode(FileSystem::read($metaFile), forceArrays: true); + $meta = is_array($decoded) ? $decoded : []; + } catch (\Throwable) { + return; + } + unset($meta['importedAt'], $meta['importStats']); + FileSystem::write($metaFile, Json::encode($meta, pretty: true)); + } + + private function sdfPath(int $loginId): string + { + return $this->sdfStorageDir . DIRECTORY_SEPARATOR . $loginId . '.sdf'; + } + + private function sdfMetaPath(int $loginId): string + { + return $this->sdfStorageDir . DIRECTORY_SEPARATOR . $loginId . '.json'; + } + + private function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . ' B'; + } + if ($bytes < 1024 * 1024) { + return number_format($bytes / 1024, 1, ',', ' ') . ' KB'; + } + + return number_format($bytes / (1024 * 1024), 1, ',', ' ') . ' MB'; + } + + private function safeOriginalName(string $name): string + { + $name = str_replace(["\0", "\r", "\n"], '', $name); + $name = basename($name); + + return $name !== '' ? mb_substr($name, 0, 255) : 'databaze.sdf'; } } diff --git a/app/Model/Sdf/SdfCompactExporter.php b/app/Model/Sdf/SdfCompactExporter.php new file mode 100644 index 0000000..d33ae75 --- /dev/null +++ b/app/Model/Sdf/SdfCompactExporter.php @@ -0,0 +1,106 @@ +scriptPath)) { + throw new \RuntimeException('Exportní skript bin/sdf-export.ps1 chybí.'); + } + + $workDir = dirname($sdfPath) . DIRECTORY_SEPARATOR . 'export-' . bin2hex(random_bytes(4)); + FileSystem::createDir($workDir); + $outFile = $workDir . DIRECTORY_SEPARATOR . 'export.json'; + + try { + $this->runPowershell($sdfPath, $outFile); + $raw = FileSystem::read($outFile); + $data = Json::decode($raw, forceArrays: true); + if (!is_array($data)) { + throw new \RuntimeException('Export SDF nevrátil platná data.'); + } + + return [ + 'uzivatel' => $data['uzivatel'] ?? [], + 'zakaznik' => $data['zakaznik'] ?? [], + 'faktura' => $data['faktura'] ?? [], + 'polozka' => $data['polozka'] ?? [], + 'platba' => $data['platba'] ?? [], + ]; + } finally { + FileSystem::delete($workDir); + } + } + + private function runPowershell(string $sdfPath, string $outFile): void + { + $powershell = $this->findPowershell(); + $cmd = sprintf( + '%s -NoProfile -NonInteractive -ExecutionPolicy Bypass -File %s -SdfPath %s -OutFile %s -Password %s', + $this->winArg($powershell), + $this->winArg($this->scriptPath), + $this->winArg($sdfPath), + $this->winArg($outFile), + $this->winArg($this->sdfPassword) + ); + + $descriptor = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($cmd, $descriptor, $pipes, null, null, ['bypass_shell' => false]); + if (!is_resource($process)) { + throw new \RuntimeException('Nepodařilo se spustit PowerShell pro čtení SDF.'); + } + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($process); + + if ($code !== 0 || !is_file($outFile)) { + $detail = trim($stderr . "\n" . $stdout); + throw new \RuntimeException('Export SDF selhal' . ($detail !== '' ? ': ' . $detail : '.')); + } + } + + private function findPowershell(): string + { + $candidates = [ + getenv('SystemRoot') . '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + ]; + foreach ($candidates as $path) { + if ($path && is_file($path)) { + return $path; + } + } + + return 'powershell.exe'; + } + + private function winArg(string $value): string + { + return '"' . str_replace('"', '\\"', $value) . '"'; + } +} diff --git a/app/Model/Sdf/SdfKufrikImporter.php b/app/Model/Sdf/SdfKufrikImporter.php new file mode 100644 index 0000000..cf7b56e --- /dev/null +++ b/app/Model/Sdf/SdfKufrikImporter.php @@ -0,0 +1,328 @@ +databazeFacade->getById($loginId); + if (!$login) { + throw new \InvalidArgumentException('Login / databáze nebyla nalezena.'); + } + + $sdfPath = $this->databazeFacade->getSdfFilePath($loginId); + if ($sdfPath === null) { + throw new \RuntimeException('K této databázi ještě není nahraný soubor SDF.'); + } + + $exported = $this->sdfExporter->export($sdfPath); + $data = [ + 'uzivatel' => $this->rows($exported['uzivatel']), + 'zakaznik' => $this->rows($exported['zakaznik']), + 'faktura' => $this->rows($exported['faktura']), + 'polozka' => $this->rows($exported['polozka']), + 'platba' => $this->rows($exported['platba']), + ]; + $firmaId = (int) $login->FIRMA_ID; + $explorer = $this->kufrikConnectionFactory->createTenantExplorer( + (string) $login->SERVER, + (string) $login->DATABASE, + $firmaId + ); + + $explorer->getConnection()->beginTransaction(); + try { + $this->wipeTenantData($explorer, $firmaId); + $firmaState = $this->importFirma($explorer, $data['uzivatel'][0] ?? null, $firmaId); + $zakaznikMap = $this->importZakaznici($explorer, $data['zakaznik'], $firmaId); + $fakturaResult = $this->importFaktury($explorer, $data, $firmaId, $zakaznikMap); + $explorer->getConnection()->commit(); + } catch (\Throwable $e) { + $explorer->getConnection()->rollBack(); + throw $e; + } + + $stats = [ + 'database' => (string) $login->DATABASE, + 'firma' => $firmaState, + 'zakazniciInserted' => $zakaznikMap['inserted'], + 'zakazniciMatched' => $zakaznikMap['matched'], + 'fakturyInserted' => $fakturaResult['inserted'], + 'fakturySkipped' => $fakturaResult['skipped'], + 'polozkyInserted' => $fakturaResult['polozky'], + 'platbyInserted' => $fakturaResult['platby'], + ]; + + $this->databazeFacade->markSdfImported($loginId, $stats); + + return $stats; + } + + private function wipeTenantData(Explorer $explorer, int $firmaId): void + { + $explorer->table('FAKTURA')->where('FIRMA_ID', $firmaId)->delete(); + $explorer->table('POLOZKA')->where('FIRMA_ID', $firmaId)->delete(); + $explorer->table('ZAKAZNIK')->where('FIRMA_ID', $firmaId)->delete(); + } + + private function importFirma(Explorer $explorer, ?array $uzivatel, int $firmaId): string + { + if (!$uzivatel) { + return 'skipped'; + } + + $banka = (string) ($uzivatel['BANKA'] ?? ''); + $cisloUctu = $banka; + $bankaKod = null; + if (preg_match('#^(.*)/(\d{4})$#u', trim($banka), $m)) { + $cisloUctu = trim($m[1]); + $bankaKod = $m[2]; + } + + $nazev = trim(($uzivatel['JMENO'] ?? '') . ' ' . ($uzivatel['PRIJMENI'] ?? '')); + if ($nazev === '') { + $nazev = 'Importovaná firma'; + } + + $values = [ + 'NAZEV' => $this->clip($nazev, 255), + 'IC' => $this->clip($uzivatel['IC'] ?? null, 8), + 'DIC' => $this->clip($uzivatel['DIC'] ?? null, 12), + 'PLATCE_DPH' => !empty($uzivatel['DIC']) ? 1 : 0, + 'TELEFON' => $this->clip($uzivatel['TELEFON'] ?? null, 20), + 'EMAIL' => $this->clip($uzivatel['EMAIL'] ?? null, 100), + 'ULICE' => $this->clip($uzivatel['ULICE'] ?? null, 100), + 'MESTO' => $this->clip($uzivatel['MESTO'] ?? null, 100), + 'PSC' => $this->clip($uzivatel['PSC'] ?? null, 10), + 'CISLO_UCTU' => $this->clip($cisloUctu, 30), + 'BANKA_KOD' => $this->clip($bankaKod, 10), + ]; + + $exists = $explorer->table('FIRMA')->get($firmaId); + if ($exists) { + $explorer->table('FIRMA')->where('ID', $firmaId)->update($values); + return 'updated'; + } + + $values['ID'] = $firmaId; + $explorer->query('SET IDENTITY_INSERT dbo.FIRMA ON'); + try { + $explorer->table('FIRMA')->insert($values); + } finally { + $explorer->query('SET IDENTITY_INSERT dbo.FIRMA OFF'); + } + + return 'inserted'; + } + + /** + * @param array> $rows + * @return array{map: array, inserted: int, matched: int} + */ + private function importZakaznici(Explorer $explorer, array $rows, int $firmaId): array + { + $map = []; + $inserted = 0; + + foreach ($rows as $row) { + $oldId = (int) ($row['ID'] ?? 0); + $ic = trim((string) ($row['IC'] ?? '')); + $insertedRow = $explorer->table('ZAKAZNIK')->insert([ + 'FIRMA_ID' => $firmaId, + 'JMENO' => $this->clip($row['JMENO'] ?? null, 255), + 'PRIJMENI' => $this->clip($row['PRIJMENI'] ?? null, 255) ?? '—', + 'ULICE' => $this->clip($row['ULICE'] ?? null, 50), + 'MESTO' => $this->clip($row['MESTO'] ?? null, 50), + 'PSC' => $this->clip($row['PSC'] ?? null, 5), + 'IC' => $this->clip($ic !== '' ? $ic : null, 8), + 'DIC' => $this->clip($row['DIC'] ?? null, 10), + 'TELEFON' => $this->clip($row['TELEFON'] ?? null, 15), + 'EMAIL' => $this->clip($row['EMAIL'] ?? null, 100), + 'AKTIVNI' => 1, + ]); + $inserted++; + if ($oldId) { + $map[$oldId] = (int) $insertedRow->ID; + } + } + + return ['map' => $map, 'inserted' => $inserted, 'matched' => 0]; + } + + /** + * @param array{faktura: array, polozka: array, platba: array} $data + * @param array{map: array, inserted: int, matched: int} $zakaznikMap + * @return array{inserted: int, skipped: int, polozky: int, platby: int} + */ + private function importFaktury(Explorer $explorer, array $data, int $firmaId, array $zakaznikMap): array + { + $polozkyByFaktura = []; + foreach ($data['polozka'] as $item) { + $polozkyByFaktura[(int) $item['FAKTURA']][] = $item; + } + $platbyByFaktura = []; + foreach ($data['platba'] as $item) { + $platbyByFaktura[(int) $item['FAKTURA']][] = $item; + } + + $inserted = 0; + $skipped = 0; + $polozkyInserted = 0; + $platbyInserted = 0; + + foreach ($data['faktura'] as $row) { + $cislo = $this->clip($row['CISLO'] ?? null, 20); + if ($cislo === null) { + $skipped++; + continue; + } + + $oldFakturaId = (int) $row['ID']; + $oldZakaznikId = (int) ($row['ZAKAZNIK'] ?? 0); + $zakaznikId = $zakaznikMap['map'][$oldZakaznikId] ?? null; + $datumVystaveni = $this->toDate($row['DATUM_VYSTAVENI'] ?? null); + $datumSplatnosti = $this->toDate($row['DATUM_SPLATNOSTI'] ?? null) ?? $datumVystaveni; + if ($datumVystaveni === null) { + $skipped++; + continue; + } + + $hasPlatba = !empty($platbyByFaktura[$oldFakturaId]); + $dBanka = $this->clip($row['D_BANKA'] ?? null, 100); + + $fakturaId = (int) $explorer->table('FAKTURA')->insert([ + 'FIRMA_ID' => $firmaId, + 'ZAKAZNIK' => $zakaznikId, + 'CISLO' => $cislo, + 'CISLO_ABS' => (int) ($row['CISLO_ABS'] ?? 0), + 'FORMA_UHRADY' => (int) ($row['FORMA_UHRADY'] ?? 1), + 'DATUM_VYSTAVENI' => $datumVystaveni, + 'DATUM_SPLATNOSTI' => $datumSplatnosti, + 'DATUM_DUZP' => $datumVystaveni, + 'D_PRAJM' => $this->clip($row['D_PRAJM'] ?? null, 511), + 'D_ULICE' => $this->clip($row['D_ULICE'] ?? null, 50), + 'D_MESTO' => $this->clip($row['D_MESTO'] ?? null, 50), + 'D_PSC' => $this->clip($row['D_PSC'] ?? null, 50), + 'D_IC' => $this->clip($row['D_IC'] ?? null, 8), + 'D_DIC' => $this->clip($row['D_DIC'] ?? null, 10), + 'D_BANKA' => $dBanka, + 'D_TELEFON' => $this->clip($row['D_TELEFON'] ?? null, 15), + 'D_EMAIL' => $this->clip($row['D_EMAIL'] ?? null, 100), + 'D_WEB' => $this->clip($row['D_WEB'] ?? null, 100), + 'O_PRAJM' => $this->clip($row['O_PRAJM'] ?? null, 511), + 'O_ULICE' => $this->clip($row['O_ULICE'] ?? null, 50), + 'O_MESTO' => $this->clip($row['O_MESTO'] ?? null, 50), + 'O_PSC' => $this->clip($row['O_PSC'] ?? null, 5), + 'O_IC' => $this->clip($row['O_IC'] ?? null, 8), + 'O_DIC' => $this->clip($row['O_DIC'] ?? null, 10), + 'CINNOSTI' => $row['CINNOSTI'] ?: null, + 'STAV' => $hasPlatba ? 'PAID' : 'ISSUED', + ])->ID; + + $poradi = 1; + foreach ($polozkyByFaktura[$oldFakturaId] ?? [] as $polozka) { + $explorer->table('POLOZKA')->insert([ + 'FAKTURA' => $fakturaId, + 'FIRMA_ID' => $firmaId, + 'PORADI' => $poradi++, + 'NAZEV' => $this->clip($polozka['NAZEV'] ?? null, 255) ?? 'Položka', + 'POCET' => (float) ($polozka['POCET'] ?? 1), + 'JEDNOTKA' => $this->clip($polozka['JEDNOTKA'] ?? null, 10) ?? 'ks', + 'CENA_JEDNOTKA' => (float) ($polozka['CENA_JEDNOTKA'] ?? 0), + 'SLEVA' => (float) ($polozka['SLEVA'] ?? 0), + 'CENA' => (float) ($polozka['CENA'] ?? 0), + ]); + $polozkyInserted++; + } + + foreach ($platbyByFaktura[$oldFakturaId] ?? [] as $platba) { + $explorer->table('PLATBA')->insert([ + 'FAKTURA' => $fakturaId, + 'CASTKA' => (float) ($platba['CASTKA'] ?? 0), + 'DATUM' => $this->toDateTime($platba['DATUM'] ?? null) ?? $datumVystaveni, + 'POZNAMKA' => $platba['POZNAMKA'] ?: null, + ]); + $platbyInserted++; + } + + $inserted++; + } + + return [ + 'inserted' => $inserted, + 'skipped' => $skipped, + 'polozky' => $polozkyInserted, + 'platby' => $platbyInserted, + ]; + } + + private function rows(mixed $value): array + { + if (!is_array($value) || $value === []) { + return []; + } + + return array_is_list($value) ? $value : [$value]; + } + + private function clip(mixed $value, int $max): ?string + { + if ($value === null || $value === '') { + return null; + } + $value = trim((string) $value); + if ($value === '') { + return null; + } + + return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value; + } + + private function toDate(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + try { + return (new \DateTimeImmutable((string) $value))->format('Y-m-d'); + } catch (\Throwable) { + return null; + } + } + + private function toDateTime(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + try { + return (new \DateTimeImmutable((string) $value))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/UI/Admin/AdminPresenter.php b/app/UI/Admin/AdminPresenter.php index 7ca7798..22e4053 100644 --- a/app/UI/Admin/AdminPresenter.php +++ b/app/UI/Admin/AdminPresenter.php @@ -3,12 +3,21 @@ namespace App\UI\Admin; use App\Model\Facade\DatabazeFacade; +use App\Model\Sdf\SdfKufrikImporter; use App\UI\BasePresenter; +use Nette\Application\AbortException; +use Nette\Application\UI\Form; +use Nette\Forms\Controls\UploadControl; +use Nette\Forms\Helpers; +use Nette\Http\FileUpload; class AdminPresenter extends BasePresenter { + private const SDF_MAX_BYTES = 50 * 1024 * 1024; + public function __construct( - private DatabazeFacade $databazeFacade + private DatabazeFacade $databazeFacade, + private SdfKufrikImporter $sdfImporter ) { parent::__construct(); } @@ -46,4 +55,68 @@ class AdminPresenter extends BasePresenter public function renderStatistika(): void { } + + protected function createComponentSdfImportForm(): Form + { + $form = new Form(); + + $form->addHidden('loginId') + ->addRule($form::Integer, 'Vyberte databázi.') + ->setRequired('Vyberte databázi.'); + + $iniLimits = array_filter([ + Helpers::iniGetSize('upload_max_filesize'), + Helpers::iniGetSize('post_max_size'), + ]); + $maxBytes = min(self::SDF_MAX_BYTES, ...($iniLimits ?: [self::SDF_MAX_BYTES])); + $maxMb = max(1, (int) floor($maxBytes / (1024 * 1024))); + + $form->addUpload('sdf', 'Soubor SDF:') + ->setRequired('Vyberte soubor s příponou .sdf.') + ->addRule($form::MaxFileSize, "Soubor může mít nejvýše {$maxMb} MB.", $maxBytes) + ->addRule( + function (UploadControl $control): bool { + $file = $control->getValue(); + if (!$file instanceof FileUpload || !$file->hasFile()) { + return false; + } + + return strtolower(pathinfo($file->getUntrustedName(), PATHINFO_EXTENSION)) === 'sdf'; + }, + 'Soubor musí mít příponu .sdf.' + ); + + $form->addSubmit('send', 'Import'); + $form->onSuccess[] = [$this, 'sdfImportFormSucceeded']; + + return $form; + } + + public function sdfImportFormSucceeded(Form $form, \stdClass $data): void + { + set_time_limit(180); + $loginId = (int) $data->loginId; + + try { + $this->databazeFacade->saveSdf($loginId, $data->sdf); + $stats = $this->sdfImporter->import($loginId); + $this->flashMessage( + sprintf( + 'Import ze SDF do databáze %s dokončen. Původní data byla smazána. Zákazníci: %d. Faktury: %d. Položky: %d. Platby: %d.', + $stats['database'], + $stats['zakazniciInserted'], + $stats['fakturyInserted'], + $stats['polozkyInserted'], + $stats['platbyInserted'] + ), + 'success' + ); + } catch (AbortException $e) { + throw $e; + } catch (\Throwable $e) { + $this->flashMessage('Import ze SDF selhal: ' . $e->getMessage(), 'danger'); + } + + $this->redirect('this'); + } } diff --git a/app/UI/Admin/databaze.latte b/app/UI/Admin/databaze.latte index adbac4c..5ed84f6 100644 --- a/app/UI/Admin/databaze.latte +++ b/app/UI/Admin/databaze.latte @@ -8,6 +8,18 @@

Přehled databází

+
+ + + +
+ +
+
    +
  • {$error}
  • +
+
+
@@ -35,6 +47,8 @@ Poslední přihlášení Stav Role + Poslední import SDF + Akce @@ -69,6 +83,31 @@ Uživatel {/if} + + {if $login->SDF_SOUBOR} +
{$login->SDF_SOUBOR}
+
+ nahráno {$login->SDF_NAHRANO|date:'d.m.Y H:i'} + {if $login->SDF_VELIKOST_TEXT} + · {$login->SDF_VELIKOST_TEXT} + {/if} +
+
+ importováno {$login->SDF_IMPORTOVANO|date:'d.m.Y H:i'} +
+ {else} + — + {/if} + + + + {/foreach} @@ -90,6 +129,26 @@ $(document).ready(function() { order: [[0, 'asc']], pageLength: 25 }); + + const $form = $('form[id$="-sdfImportForm"], #frm-sdfImportForm').first(); + const $loginId = $form.find('[name="loginId"]'); + const $file = $form.find('input[type="file"]'); + + $(document).on('click', '.js-sdf-import', function() { + const database = $(this).data('database'); + if (!confirm('VAROVÁNÍ: Import ze SDF do databáze ' + database + ' SMAŽE všechna stávající data této firmy (zákazníky, faktury, položky a platby) a nahradí je obsahem souboru.\n\nOpravdu chcete pokračovat?')) { + return; + } + $loginId.val($(this).data('login-id')); + $file.val(''); + $file.trigger('click'); + }); + + $file.on('change', function() { + if (this.files && this.files.length) { + $form.trigger('submit'); + } + }); }); {/syntax} diff --git a/bin/sdf-export.ps1 b/bin/sdf-export.ps1 new file mode 100644 index 0000000..443d00f --- /dev/null +++ b/bin/sdf-export.ps1 @@ -0,0 +1,135 @@ +param( + [Parameter(Mandatory = $true)] + [string] $SdfPath, + + [Parameter(Mandatory = $true)] + [string] $OutFile, + + [string] $Password = '', + + [string] $SqlCeDll = '' +) + +$ErrorActionPreference = 'Stop' + +function Find-SqlCeDll([string] $preferred) { + if ($preferred -and (Test-Path -LiteralPath $preferred)) { + return $preferred + } + + $candidates = @( + 'C:\Program Files\Microsoft SQL Server Compact Edition\v4.0\Desktop\System.Data.SqlServerCe.dll', + 'C:\Program Files (x86)\Microsoft SQL Server Compact Edition\v4.0\Desktop\System.Data.SqlServerCe.dll' + ) + foreach ($path in $candidates) { + if (Test-Path -LiteralPath $path) { + return $path + } + } + + throw 'Nenalezena knihovna System.Data.SqlServerCe.dll (SQL Server Compact 4.0).' +} + +function New-ConnectionString([string] $path, [string] $password) { + $cs = "Data Source=$path;Max Database Size=4091" + if ($password -ne '') { + $cs += ";Password=$password" + } + return $cs +} + +function Read-Table($connection, [string] $sql) { + $cmd = $connection.CreateCommand() + $cmd.CommandText = $sql + $adapter = New-Object System.Data.SqlServerCe.SqlCeDataAdapter($cmd) + $table = New-Object System.Data.DataTable + [void] $adapter.Fill($table) + + $rows = New-Object System.Collections.Generic.List[object] + foreach ($row in $table.Rows) { + $item = [ordered] @{} + foreach ($col in $table.Columns) { + $value = $row[$col] + if ($value -is [DBNull]) { + $item[$col.ColumnName] = $null + } elseif ($value -is [datetime]) { + $item[$col.ColumnName] = ([datetime] $value).ToString('o') + } else { + $item[$col.ColumnName] = $value + } + } + $rows.Add([pscustomobject] $item) + } + + return $rows +} + +function Open-Sdf([string] $path, [string] $password) { + $cs = New-ConnectionString $path $password + try { + $conn = New-Object System.Data.SqlServerCe.SqlCeConnection($cs) + $conn.Open() + return $conn + } catch { + $message = $_.Exception.Message + if ($message -notmatch 'earlier version|Upgrade') { + throw + } + $engine = New-Object System.Data.SqlServerCe.SqlCeEngine($cs) + $engine.Upgrade() + $conn = New-Object System.Data.SqlServerCe.SqlCeConnection($cs) + $conn.Open() + return $conn + } +} + +if (-not (Test-Path -LiteralPath $SdfPath)) { + throw "SDF soubor neexistuje: $SdfPath" +} + +Add-Type -Path (Find-SqlCeDll $SqlCeDll) + +$workDir = Split-Path -Parent $OutFile +if (-not (Test-Path -LiteralPath $workDir)) { + New-Item -ItemType Directory -Path $workDir | Out-Null +} + +$workSdf = Join-Path $workDir 'source.sdf' +Copy-Item -LiteralPath $SdfPath -Destination $workSdf -Force + +$passwords = @() +if ($Password -ne '') { $passwords += $Password } +$passwords += '' + +$connection = $null +$lastError = $null +foreach ($pw in $passwords) { + try { + $connection = Open-Sdf $workSdf $pw + break + } catch { + $lastError = $_ + $connection = $null + } +} + +if (-not $connection) { + throw "Nepodařilo se otevřít SDF databázi. $($lastError.Exception.Message)" +} + +try { + $payload = [ordered] @{ + uzivatel = @(Read-Table $connection 'SELECT * FROM UZIVATEL ORDER BY ID') + zakaznik = @(Read-Table $connection 'SELECT * FROM ZAKAZNIK ORDER BY ID') + faktura = @(Read-Table $connection 'SELECT * FROM FAKTURA ORDER BY ID') + polozka = @(Read-Table $connection 'SELECT * FROM POLOZKA ORDER BY ID') + platba = @(Read-Table $connection 'SELECT * FROM PLATBA ORDER BY ID') + } + + $json = $payload | ConvertTo-Json -Depth 6 -Compress:$false + [System.IO.File]::WriteAllText($OutFile, $json, [System.Text.UTF8Encoding]::new($false)) +} finally { + if ($connection -and $connection.State -eq 'Open') { + $connection.Close() + } +} diff --git a/config/common.neon b/config/common.neon index aa91041..51a0a6e 100644 --- a/config/common.neon +++ b/config/common.neon @@ -4,6 +4,11 @@ parameters: user: kufrik password: 'giteaKufrik_2026!' + # Nahrané SDF soubory (SQL Server Compact) a heslo staré databáze + sdfStorageDir: %appDir%/../data/sdf + sdfPassword: 'Kmetix2016_' + sdfExportScript: %appDir%/../bin/sdf-export.ps1 + application: errorPresenter: 4xx: Error:Error4xx diff --git a/config/services.neon b/config/services.neon index ba45d08..99e1eac 100644 --- a/config/services.neon +++ b/config/services.neon @@ -7,7 +7,10 @@ services: - App\Model\Security\Authenticator(@database.default.connection) # Seznam databází (LOGIN) z Master DB - - App\Model\Facade\DatabazeFacade(@database.default.connection) + - App\Model\Facade\DatabazeFacade(@database.default.connection, %sdfStorageDir%) + + - App\Model\Sdf\SdfCompactExporter(%sdfExportScript%, %sdfPassword%) + - App\Model\Sdf\SdfKufrikImporter # 1. Factory pro klientskou databázi kufrikConnectionFactory: diff --git a/data/sdf/.gitkeep b/data/sdf/.gitkeep new file mode 100644 index 0000000..e69de29