Import ze souboru SDF

master
Kowalski 2026-10-10 17:39:24 +02:00
parent 3688c0312d
commit 28f30fa887
11 changed files with 913 additions and 13 deletions

2
.gitignore vendored
View File

@ -6,6 +6,8 @@
# KOW
temp/
log/
data/sdf/*
!data/sdf/.gitkeep
#ZAZA

View File

@ -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);

View File

@ -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<string, mixed> $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<int, string>
*/
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';
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Model\Sdf;
use Nette\Utils\FileSystem;
use Nette\Utils\Json;
class SdfCompactExporter
{
public function __construct(
private string $scriptPath,
private string $sdfPassword
) {
}
/**
* Přečte SQL Compact .sdf a vrátí tabulky jako pole.
*
* @return array{uzivatel: array, zakaznik: array, faktura: array, polozka: array, platba: array}
*/
public function export(string $sdfPath): array
{
if (!is_file($sdfPath)) {
throw new \RuntimeException('SDF soubor nebyl nalezen.');
}
if (!is_file($this->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) . '"';
}
}

View File

@ -0,0 +1,328 @@
<?php
namespace App\Model\Sdf;
use App\Model\Database\KufrikConnectionFactory;
use App\Model\Facade\DatabazeFacade;
use Nette\Database\Explorer;
class SdfKufrikImporter
{
public function __construct(
private DatabazeFacade $databazeFacade,
private SdfCompactExporter $sdfExporter,
private KufrikConnectionFactory $kufrikConnectionFactory
) {
}
/**
* @return array{
* database: string,
* firma: string,
* zakazniciInserted: int,
* zakazniciMatched: int,
* fakturyInserted: int,
* fakturySkipped: int,
* polozkyInserted: int,
* platbyInserted: int
* }
*/
public function import(int $loginId): array
{
$login = $this->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<int, array<string, mixed>> $rows
* @return array{map: array<int, int>, 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<int, int>, 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;
}
}
}

View File

@ -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');
}
}

View File

@ -8,6 +8,18 @@
<h2 class="h3 mb-0">Přehled databází</h2>
</div>
<form n:name="sdfImportForm" class="d-none">
<input n:name="loginId">
<input n:name="sdf" accept=".sdf">
<button n:name="send">Import</button>
</form>
<div class="alert alert-danger" n:if="$presenter['sdfImportForm']->hasErrors()">
<ul class="mb-0 ps-3">
<li n:foreach="$presenter['sdfImportForm']->errors as $error">{$error}</li>
</ul>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm p-3 bg-white border-start border-primary border-4">
@ -35,6 +47,8 @@
<th>Poslední přihlášení</th>
<th class="text-center">Stav</th>
<th class="text-center">Role</th>
<th>Poslední import SDF</th>
<th class="text-end" data-orderable="false">Akce</th>
</tr>
</thead>
<tbody>
@ -69,6 +83,31 @@
<span class="badge bg-light text-dark border">Uživatel</span>
{/if}
</td>
<td data-order="{$login->SDF_SOUBOR}">
{if $login->SDF_SOUBOR}
<div class="fw-semibold">{$login->SDF_SOUBOR}</div>
<div class="small text-muted">
nahráno {$login->SDF_NAHRANO|date:'d.m.Y H:i'}
{if $login->SDF_VELIKOST_TEXT}
· {$login->SDF_VELIKOST_TEXT}
{/if}
</div>
<div class="small text-success" n:if="$login->SDF_IMPORTOVANO">
importováno {$login->SDF_IMPORTOVANO|date:'d.m.Y H:i'}
</div>
{else}
<span class="text-muted">—</span>
{/if}
</td>
<td class="text-end text-nowrap">
<button type="button"
class="btn btn-sm btn-success js-sdf-import"
data-login-id="{$login->ID}"
data-database="{$login->DATABASE}"
title="Smaže stávající zákazníky, faktury, položky a platby a nahradí je daty ze SDF">
<i class="bi bi-box-arrow-in-down me-1"></i>Import
</button>
</td>
</tr>
{/foreach}
</tbody>
@ -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');
}
});
});
</script>
{/syntax}

135
bin/sdf-export.ps1 Normal file
View File

@ -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()
}
}

View File

@ -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

View File

@ -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:

0
data/sdf/.gitkeep Normal file
View File