Přeskočit obsah

Architektura a vzory


Result pattern

Všechny servisní operace vrací Result nebo Result<T> (Eva.Data/Common/Result.cs).

// Úspěšný výsledek
return Result.Success();
return Result<Article>.Success(article);

// Chybový výsledek
return Result.Failure("Majetek nenalezen.");
return Result.Failure(new[] { "Chyba 1", "Chyba 2" });

Vlastnosti Result:

Vlastnost Typ Popis
Succeeded bool Operace proběhla úspěšně
Errors List<string> Seznam chybových zpráv
Data T Výsledná data (pouze Result<T>)

Použití v controlleru:

var result = await _service.CreateAsync(article);
if (!result.Succeeded)
    return BadRequest(result.Errors);
return CreatedAtRoute(..., result.Data);

BaseDataService\<T> — Generický CRUD

Všechny datové služby dědí z BaseDataService<T> a dostávají IDbContextFactory<EvaDataContext>:

public class ArticleDataService : BaseDataService<Article>, IArticleDataService
{
    public ArticleDataService(IDbContextFactory<EvaDataContext> factory) : base(factory) { }
}

Dostupné metody:

Task<IEnumerable<T>>  GetAllAsync()
Task<IQueryable<T>>   GetAllAsQueryableAsync()
Task<T?>              GetByIdAsync(long id)
Task<T>               CreateAsync(T entity)
Task                  UpdateAsync(T entity)
Task                  DeleteAsync(T entity, bool setIsDeleted = true)
Task                  SetPropertyValueAsync(long id, string prop, object value)
Task                  RemoveItemsAsync(IEnumerable<T> items)
Task<IEnumerable<T>>  SearchAsync(Expression<Func<T, bool>> predicate)

EF Core — vzory použití

// Vždy používat factory pattern — nikdy singleton DbContext
await using var db = await _factory.CreateDbContextAsync();

// Read operace — vždy NoTracking
var articles = await db.Articles
    .AsNoTracking()
    .Include(a => a.Category)
    .ToListAsync();

// Write operace
await using var db = await _factory.CreateDbContextAsync();
db.Articles.Add(article);
await db.SaveChangesAsync();

Uložené procedury — od v1.4.2 nahrazeny C# metodami (SP neměly TenantID filtr):

Metoda Stará SP Nová C# metoda
Generování položek inventury sg_CreateInventoryItems InventoryDataService.CreateInventoryItemsFromLocationAsync
Provedení přesunu sg_ExecuteArticleTransfer ArticleTransferDataService.ExecuteTransferWithAuditAsync
Uzavření inventury sg_UpdateArticlesAfterInventory InventoryDataService.CloseInventoryWithAuditAsync
// Nové C# metody — tenant-safe, s audit logem
var result = await inventoryDataService
    .CreateInventoryItemsFromLocationAsync(inventoryId, locationId, force: false);
// result.Errors[0] == IInventoryDataService.ConfirmOverwriteError → UI zobrazí potvrzení

var execResult = await articleTransferDataService
    .ExecuteTransferWithAuditAsync(transfer);
// execResult.Data: TransferExecuteResult { LocationChanges, StateChanges, UserChanges }

Dapper — výkonnostní dotazy

Dapper se používá souběžně s EF Core pro složitější READ dotazy:

await using var db = await _factory.CreateDbContextAsync();
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
var result = await connection.QueryAsync<ArticleDto>(
    "SELECT a.\"OID\", a.\"Name\", l.\"Name\" as LocationName FROM dbo.\"Article\" a ...",
    new { locationId });

MVVM (Eva.Mobile — MAUI)

// ViewModel s CommunityToolkit.Mvvm
[ObservableObject]
public partial class InventoryListViewModel
{
    [ObservableProperty]
    private ObservableCollection<Inventory> _inventories = [];

    [RelayCommand]
    private async Task LoadInventoriesAsync()
    {
        var result = await _restService.GetInventoriesAsync();
        Inventories = new ObservableCollection<Inventory>(result);
    }
}

Komunikace mezi ViewModels:

// Odeslání zprávy
WeakReferenceMessenger.Default.Send(new ArticleSelectedMessage(article));

// Přihlášení k odběru
WeakReferenceMessenger.Default.Register<ArticleSelectedMessage>(this, (r, m) => {
    SelectedArticle = m.Article;
});

Multitenancy

EVA podporuje SaaS multi-tenant režim — jedna DB instance obsluhuje více organizací s plnou izolací dat.

Konfigurace

// appsettings.json
{ "Multitenancy": { "Enabled": true } }

Architektura tenant kontextu

Přihlášení uživatele
TenantClaimsPrincipalFactory
    └─ přidá claim "tenant_id" = user.TenantID do auth cookie

HTTP request / Blazor circuit
ClaimsPrincipalTenantAccessor  (Singleton)
    ├─ 1. Cookie claim: HttpContext?.User.FindFirst("tenant_id")
    └─ 2. Per-circuit cache: ConcurrentDictionary[SyncCtxKey]
              │ SetTenant(user.TenantID) volán z MainLayout.OnInitializedAsync

EvaDataContext  (per-query instance přes IDbContextFactory)
    └─ HasQueryFilter: !IsMultitenancyEnabled || e.TenantID == accessor.TenantID
              ▼ EF Core SQL
    WHERE (@p2 OR "TenantID" = @tenantId)   ← správně
    WHERE (@p2 OR "TenantID" IS NULL)        ← chyba: TenantID null v accessoru

ITenantAware + Global Query Filter

Každá entita s tenant-specifickými daty implementuje ITenantAware (28 entit: Article, Location, Inventory, Report, Printer, ...). EvaDataContext automaticky aplikuje filtr na všechny tyto entity:

// EvaDataContext — volá se jednou při ModelBuilding
modelBuilder.Entity<T>().HasQueryFilter(e =>
    !_tenantAccessor.IsMultitenancyEnabled || e.TenantID == _tenantAccessor.TenantID);

Výsledek: žádný EF Core dotaz nemůže vrátit data jiného tenanta bez explicitního IgnoreQueryFilters().

Proč ConcurrentDictionary, ne AsyncLocal

AsyncLocal<T> propaguje hodnotu pouze do child-tasků (kopie při vzniku tasku). Sibling async operace (MainLayout a DxGrid data source) sdílejí stejný Blazor circuit, ale běží v různých async execution kontextech — hodnota nastavená v MainLayout.OnInitializedAsync se proto nepropaguje do kontextu, kde DxGrid spouští EF Core dotaz.

Důsledek: _tenantAccessor.TenantID vrátí null → EF Core generuje WHERE TenantID IS NULL → žádná data.

Řešení: ConcurrentDictionary<int, long?> keyovaný přes SynchronizationContext.Current.GetHashCode(). V Blazor Server má každý circuit vlastní RendererSynchronizationContext — jeho hash je stabilní pro celý circuit a sdílený napříč všemi render fázemi (MainLayout, stránky, DxGrid callbacky).

// SetTenant — volán z MainLayout.OnInitializedAsync
public void SetTenant(long? tenantId)
{
    var key = SynchronizationContext.Current?.GetHashCode()
              ?? Thread.CurrentThread.ManagedThreadId;
    if (tenantId.HasValue)
        _circuitTenantMap[key] = tenantId;
    else
        _circuitTenantMap.TryRemove(key, out _);
}

// TenantID — čteno při každém EF Core dotazu
public long? TenantID
{
    get
    {
        // 1. Cookie claim (spolehlivé pro HTTP a počáteční render)
        var claim = httpContextAccessor.HttpContext?.User.FindFirst("tenant_id");
        if (claim != null && long.TryParse(claim.Value, out var id))
            return id;
        // 2. Per-circuit cache (spolehlivé pro DxGrid, navigaci, re-rendery)
        var key = SynchronizationContext.Current?.GetHashCode()
                  ?? Thread.CurrentThread.ManagedThreadId;
        _circuitTenantMap.TryGetValue(key, out var cached);
        return cached;
    }
}

Záchranná síť při zápisu

EvaDataContext.SaveChangesAsync override automaticky doplní TenantID na všech Added entitách, kde je null. Pokrývá kód, který volá context.Add() přímo mimo BaseDataService.CreateAsync.

// EvaDataContext.cs — spustí se před každým SaveChanges
private void ApplyTenantIdToAddedEntities()
{
    if (!_tenantAccessor.IsMultitenancyEnabled || _tenantAccessor.TenantID == null) return;
    foreach (var entry in ChangeTracker.Entries<ITenantAware>()
        .Where(e => e.State == EntityState.Added && e.Entity.TenantID == null))
    {
        entry.Entity.TenantID = _tenantAccessor.TenantID;
    }
}

Automatické nastavení TenantID

BaseDataService.CreateAsync() nastaví TenantID automaticky. Záchranná síť v SaveChanges pokryje i přímé context.Add() volání.

DB Unique indexy

Všechny unique indexy na tenant-aware tabulkách obsahují TenantID, např.: - UK_Article_Code_Tenant (Code, TenantID) - UIX_t_Report_Name (Name, TenantID) - UK_Printer_PrinterCD (PrinterCD, TenantID)

Diagnostika problémů

Pokud se data nezobrazují po importu nebo přihlášení:

  1. Ověř SQL log — hledej WHERE TenantID IS NULL (chyba) vs WHERE TenantID = @p (správně).
  2. Ověř přihlášení — po novém Docker deploy jsou staré cookies neplatné (rotace DataProtection klíčů). Uživatel se musí znovu přihlásit.
  3. Ověř DBSELECT COUNT(*), "TenantID" FROM dbo."Article" GROUP BY "TenantID" potvrdí, zda data v DB jsou.
  4. SuperAdmin nemá TenantIDApplicationUser.TenantID = null pro SuperAdmin. SetTenant(null) vymaže záznam z _circuitTenantMap. Při IsMultitenancyEnabled = true vidí SuperAdmin jen záznamy s TenantID IS NULL — pro přístup na všechna data musí SuperAdmin použít admin stránky s IgnoreQueryFilters().

Viz doc/tenant_guide.md pro kompletní přehled a troubleshooting.


DI konfigurace

Eva.Data

// Program.cs
builder.Services.AddEvaDataServices(connectionString);
// Registruje: EvaDataContext factory + všechny IXxxDataService implementace

Eva.Manager

builder.Services.AddUserServices();
// Registruje: EmailService, TransferEmailService, EvaSettingsService, SystemInfoService