Import ze souboru SDF
parent
3688c0312d
commit
28f30fa887
|
|
@ -6,6 +6,8 @@
|
|||
# KOW
|
||||
temp/
|
||||
log/
|
||||
data/sdf/*
|
||||
!data/sdf/.gitkeep
|
||||
|
||||
|
||||
#ZAZA
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) . '"';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue