This commit is contained in:
2025-11-29 13:30:28 +01:00
parent 824a761bf6
commit bb2d0729e1
16 changed files with 3102 additions and 37 deletions

View File

@@ -8,6 +8,10 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="QuestPDF" Version="2024.12.2" />
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />

View File

@@ -0,0 +1,352 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Controllers;
/// <summary>
/// Controller per la gestione dei moduli applicativi e delle subscription
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ModulesController : ControllerBase
{
private readonly ModuleService _moduleService;
private readonly ILogger<ModulesController> _logger;
public ModulesController(ModuleService moduleService, ILogger<ModulesController> logger)
{
_moduleService = moduleService;
_logger = logger;
}
/// <summary>
/// Ottiene tutti i moduli disponibili con stato subscription
/// </summary>
[HttpGet]
public async Task<ActionResult<List<ModuleDto>>> GetAllModules()
{
var modules = await _moduleService.GetAllModulesAsync();
return Ok(modules.Select(MapToDto).ToList());
}
/// <summary>
/// Ottiene solo i moduli attivi (per costruzione menu)
/// </summary>
[HttpGet("active")]
public async Task<ActionResult<List<ModuleDto>>> GetActiveModules()
{
var modules = await _moduleService.GetActiveModulesAsync();
return Ok(modules.Select(MapToDto).ToList());
}
/// <summary>
/// Ottiene un modulo specifico per codice
/// </summary>
[HttpGet("{code}")]
public async Task<ActionResult<ModuleDto>> GetModule(string code)
{
var module = await _moduleService.GetModuleByCodeAsync(code);
if (module == null)
return NotFound(new { message = $"Modulo '{code}' non trovato" });
return Ok(MapToDto(module));
}
/// <summary>
/// Verifica se un modulo è abilitato
/// </summary>
[HttpGet("{code}/enabled")]
public async Task<ActionResult<ModuleStatusDto>> IsModuleEnabled(string code)
{
var module = await _moduleService.GetModuleByCodeAsync(code);
if (module == null)
return NotFound(new { message = $"Modulo '{code}' non trovato" });
var isEnabled = await _moduleService.IsModuleEnabledAsync(code);
var hasValidSubscription = await _moduleService.HasValidSubscriptionAsync(code);
return Ok(new ModuleStatusDto
{
Code = code,
IsEnabled = isEnabled,
HasValidSubscription = hasValidSubscription,
IsCore = module.IsCore,
DaysRemaining = module.Subscription?.GetDaysRemaining(),
IsExpiringSoon = module.Subscription?.IsExpiringSoon() ?? false
});
}
/// <summary>
/// Attiva un modulo
/// </summary>
[HttpPut("{code}/enable")]
public async Task<ActionResult<SubscriptionDto>> EnableModule(string code, [FromBody] EnableModuleRequest request)
{
try
{
var subscription = await _moduleService.EnableModuleAsync(
code,
request.SubscriptionType,
request.StartDate,
request.EndDate,
request.AutoRenew,
request.PaidPrice,
request.Notes);
return Ok(MapSubscriptionToDto(subscription));
}
catch (ArgumentException ex)
{
return NotFound(new { message = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// Disattiva un modulo
/// </summary>
[HttpPut("{code}/disable")]
public async Task<ActionResult> DisableModule(string code)
{
try
{
await _moduleService.DisableModuleAsync(code);
return Ok(new { message = $"Modulo '{code}' disattivato" });
}
catch (ArgumentException ex)
{
return NotFound(new { message = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// Ottiene tutte le subscription
/// </summary>
[HttpGet("subscriptions")]
public async Task<ActionResult<List<SubscriptionDto>>> GetAllSubscriptions()
{
var subscriptions = await _moduleService.GetAllSubscriptionsAsync();
return Ok(subscriptions.Select(MapSubscriptionToDto).ToList());
}
/// <summary>
/// Aggiorna la subscription di un modulo
/// </summary>
[HttpPut("{code}/subscription")]
public async Task<ActionResult<SubscriptionDto>> UpdateSubscription(string code, [FromBody] UpdateSubscriptionRequest request)
{
try
{
var subscription = await _moduleService.UpdateSubscriptionAsync(
code,
request.SubscriptionType,
request.EndDate,
request.AutoRenew,
request.Notes);
return Ok(MapSubscriptionToDto(subscription));
}
catch (ArgumentException ex)
{
return NotFound(new { message = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// Rinnova la subscription di un modulo
/// </summary>
[HttpPost("{code}/subscription/renew")]
public async Task<ActionResult<SubscriptionDto>> RenewSubscription(string code, [FromBody] RenewSubscriptionRequest? request = null)
{
try
{
var subscription = await _moduleService.RenewSubscriptionAsync(code, request?.PaidPrice);
return Ok(MapSubscriptionToDto(subscription));
}
catch (ArgumentException ex)
{
return NotFound(new { message = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// Ottiene i moduli in scadenza
/// </summary>
[HttpGet("expiring")]
public async Task<ActionResult<List<ModuleDto>>> GetExpiringModules([FromQuery] int daysThreshold = 30)
{
var modules = await _moduleService.GetExpiringModulesAsync(daysThreshold);
return Ok(modules.Select(MapToDto).ToList());
}
/// <summary>
/// Inizializza i moduli di default (per setup iniziale)
/// </summary>
[HttpPost("seed")]
public async Task<ActionResult> SeedDefaultModules()
{
await _moduleService.SeedDefaultModulesAsync();
return Ok(new { message = "Moduli di default inizializzati" });
}
/// <summary>
/// Forza il controllo delle subscription scadute
/// </summary>
[HttpPost("check-expired")]
public async Task<ActionResult> CheckExpiredSubscriptions()
{
var count = await _moduleService.CheckExpiredSubscriptionsAsync();
return Ok(new { message = $"Controllate le subscription, {count} moduli disattivati per scadenza" });
}
/// <summary>
/// Invalida la cache dei moduli
/// </summary>
[HttpPost("invalidate-cache")]
public ActionResult InvalidateCache()
{
_moduleService.InvalidateCache();
return Ok(new { message = "Cache moduli invalidata" });
}
#region Mapping
private static ModuleDto MapToDto(AppModule module)
{
return new ModuleDto
{
Id = module.Id,
Code = module.Code,
Name = module.Name,
Description = module.Description,
Icon = module.Icon,
BasePrice = module.BasePrice,
MonthlyPrice = module.GetMonthlyPrice(),
MonthlyMultiplier = module.MonthlyMultiplier,
SortOrder = module.SortOrder,
IsCore = module.IsCore,
Dependencies = module.GetDependencies().ToList(),
RoutePath = module.RoutePath,
IsAvailable = module.IsAvailable,
IsEnabled = module.IsCore || (module.Subscription?.IsValid() ?? false),
Subscription = module.Subscription != null ? MapSubscriptionToDto(module.Subscription) : null
};
}
private static SubscriptionDto MapSubscriptionToDto(ModuleSubscription subscription)
{
return new SubscriptionDto
{
Id = subscription.Id,
ModuleId = subscription.ModuleId,
ModuleCode = subscription.Module?.Code,
ModuleName = subscription.Module?.Name,
IsEnabled = subscription.IsEnabled,
SubscriptionType = subscription.SubscriptionType,
SubscriptionTypeName = subscription.SubscriptionType.ToString(),
StartDate = subscription.StartDate,
EndDate = subscription.EndDate,
AutoRenew = subscription.AutoRenew,
Notes = subscription.Notes,
LastRenewalDate = subscription.LastRenewalDate,
PaidPrice = subscription.PaidPrice,
IsValid = subscription.IsValid(),
DaysRemaining = subscription.GetDaysRemaining(),
IsExpiringSoon = subscription.IsExpiringSoon()
};
}
#endregion
}
#region DTOs
public class ModuleDto
{
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Icon { get; set; }
public decimal BasePrice { get; set; }
public decimal MonthlyPrice { get; set; }
public decimal MonthlyMultiplier { get; set; }
public int SortOrder { get; set; }
public bool IsCore { get; set; }
public List<string> Dependencies { get; set; } = new();
public string? RoutePath { get; set; }
public bool IsAvailable { get; set; }
public bool IsEnabled { get; set; }
public SubscriptionDto? Subscription { get; set; }
}
public class SubscriptionDto
{
public int Id { get; set; }
public int ModuleId { get; set; }
public string? ModuleCode { get; set; }
public string? ModuleName { get; set; }
public bool IsEnabled { get; set; }
public SubscriptionType SubscriptionType { get; set; }
public string SubscriptionTypeName { get; set; } = string.Empty;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public bool AutoRenew { get; set; }
public string? Notes { get; set; }
public DateTime? LastRenewalDate { get; set; }
public decimal? PaidPrice { get; set; }
public bool IsValid { get; set; }
public int? DaysRemaining { get; set; }
public bool IsExpiringSoon { get; set; }
}
public class ModuleStatusDto
{
public string Code { get; set; } = string.Empty;
public bool IsEnabled { get; set; }
public bool HasValidSubscription { get; set; }
public bool IsCore { get; set; }
public int? DaysRemaining { get; set; }
public bool IsExpiringSoon { get; set; }
}
public class EnableModuleRequest
{
public SubscriptionType SubscriptionType { get; set; } = SubscriptionType.Annual;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public bool AutoRenew { get; set; }
public decimal? PaidPrice { get; set; }
public string? Notes { get; set; }
}
public class UpdateSubscriptionRequest
{
public SubscriptionType? SubscriptionType { get; set; }
public DateTime? EndDate { get; set; }
public bool? AutoRenew { get; set; }
public string? Notes { get; set; }
}
public class RenewSubscriptionRequest
{
public decimal? PaidPrice { get; set; }
}
#endregion

View File

@@ -17,8 +17,12 @@ builder.Services.AddDbContext<AppollinareDbContext>(options =>
builder.Services.AddScoped<EventoCostiService>();
builder.Services.AddScoped<DemoDataService>();
builder.Services.AddScoped<ReportGeneratorService>();
builder.Services.AddScoped<ModuleService>();
builder.Services.AddSingleton<DataNotificationService>();
// Memory cache for module state
builder.Services.AddMemoryCache();
// SignalR - with increased message size for template sync (default is 32KB)
builder.Services.AddSignalR(options =>
{
@@ -61,6 +65,11 @@ if (app.Environment.IsDevelopment())
var db = scope.ServiceProvider.GetRequiredService<AppollinareDbContext>();
db.Database.EnsureCreated();
DbSeeder.Seed(db);
// Seed default modules
var moduleService = scope.ServiceProvider.GetRequiredService<ModuleService>();
await moduleService.SeedDefaultModulesAsync();
app.MapOpenApi();
}

View File

@@ -0,0 +1,493 @@
using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Apollinare.API.Services;
/// <summary>
/// Service per la gestione dei moduli applicativi e delle relative subscription
/// </summary>
public class ModuleService
{
private readonly AppollinareDbContext _context;
private readonly IMemoryCache _cache;
private readonly ILogger<ModuleService> _logger;
private const string MODULES_CACHE_KEY = "modules_all";
private const string ACTIVE_MODULES_CACHE_KEY = "modules_active";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public ModuleService(
AppollinareDbContext context,
IMemoryCache cache,
ILogger<ModuleService> logger)
{
_context = context;
_cache = cache;
_logger = logger;
}
/// <summary>
/// Ottiene tutti i moduli con lo stato della subscription
/// </summary>
public async Task<List<AppModule>> GetAllModulesAsync()
{
return await _cache.GetOrCreateAsync(MODULES_CACHE_KEY, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return await _context.AppModules
.Include(m => m.Subscription)
.Where(m => m.IsAvailable)
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Name)
.ToListAsync();
}) ?? new List<AppModule>();
}
/// <summary>
/// Ottiene solo i moduli attivi (per la costruzione del menu)
/// </summary>
public async Task<List<AppModule>> GetActiveModulesAsync()
{
return await _cache.GetOrCreateAsync(ACTIVE_MODULES_CACHE_KEY, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
var modules = await _context.AppModules
.Include(m => m.Subscription)
.Where(m => m.IsAvailable)
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Name)
.ToListAsync();
return modules.Where(m => m.IsCore || (m.Subscription?.IsValid() ?? false)).ToList();
}) ?? new List<AppModule>();
}
/// <summary>
/// Ottiene un modulo specifico per codice
/// </summary>
public async Task<AppModule?> GetModuleByCodeAsync(string code)
{
return await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
}
/// <summary>
/// Verifica se un modulo è attualmente abilitato
/// </summary>
public async Task<bool> IsModuleEnabledAsync(string code)
{
var module = await GetModuleByCodeAsync(code);
if (module == null)
return false;
// I moduli core sono sempre abilitati
if (module.IsCore)
return true;
return module.Subscription?.IsValid() ?? false;
}
/// <summary>
/// Verifica se un modulo ha una subscription valida (non scaduta)
/// </summary>
public async Task<bool> HasValidSubscriptionAsync(string code)
{
var module = await GetModuleByCodeAsync(code);
return module?.Subscription?.IsValid() ?? false;
}
/// <summary>
/// Attiva un modulo creando o aggiornando la subscription
/// </summary>
public async Task<ModuleSubscription> EnableModuleAsync(
string code,
SubscriptionType subscriptionType,
DateTime? startDate = null,
DateTime? endDate = null,
bool autoRenew = false,
decimal? paidPrice = null,
string? notes = null)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.IsCore)
throw new InvalidOperationException("I moduli core non possono essere attivati/disattivati manualmente");
// Verifica dipendenze
var missingDeps = await CheckDependenciesAsync(module);
if (missingDeps.Any())
throw new InvalidOperationException(
$"Il modulo richiede i seguenti moduli attivi: {string.Join(", ", missingDeps)}");
var now = DateTime.UtcNow;
var effectiveStartDate = startDate ?? now;
// Calcola data fine se non specificata
DateTime? effectiveEndDate = endDate;
if (!effectiveEndDate.HasValue && subscriptionType != SubscriptionType.None)
{
effectiveEndDate = subscriptionType switch
{
SubscriptionType.Monthly => effectiveStartDate.AddMonths(1),
SubscriptionType.Annual => effectiveStartDate.AddYears(1),
_ => null
};
}
if (module.Subscription == null)
{
// Crea nuova subscription
module.Subscription = new ModuleSubscription
{
ModuleId = module.Id,
IsEnabled = true,
SubscriptionType = subscriptionType,
StartDate = effectiveStartDate,
EndDate = effectiveEndDate,
AutoRenew = autoRenew,
PaidPrice = paidPrice ?? module.BasePrice,
Notes = notes,
CreatedAt = now,
UpdatedAt = now
};
_context.ModuleSubscriptions.Add(module.Subscription);
}
else
{
// Aggiorna subscription esistente
module.Subscription.IsEnabled = true;
module.Subscription.SubscriptionType = subscriptionType;
module.Subscription.StartDate = effectiveStartDate;
module.Subscription.EndDate = effectiveEndDate;
module.Subscription.AutoRenew = autoRenew;
module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice ?? module.BasePrice;
if (notes != null) module.Subscription.Notes = notes;
module.Subscription.UpdatedAt = now;
}
await _context.SaveChangesAsync();
InvalidateCache();
_logger.LogInformation(
"Modulo {ModuleCode} attivato con subscription {Type} fino a {EndDate}",
code, subscriptionType, effectiveEndDate);
return module.Subscription;
}
/// <summary>
/// Disattiva un modulo (mantiene i dati ma rimuove l'accesso)
/// </summary>
public async Task DisableModuleAsync(string code)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.IsCore)
throw new InvalidOperationException("I moduli core non possono essere disattivati");
// Verifica se altri moduli dipendono da questo
var dependentModules = await GetDependentModulesAsync(code);
var activeDependents = dependentModules.Where(m => m.Subscription?.IsValid() ?? false).ToList();
if (activeDependents.Any())
throw new InvalidOperationException(
$"I seguenti moduli attivi dipendono da questo modulo: {string.Join(", ", activeDependents.Select(m => m.Name))}");
if (module.Subscription != null)
{
module.Subscription.IsEnabled = false;
module.Subscription.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
InvalidateCache();
_logger.LogInformation("Modulo {ModuleCode} disattivato", code);
}
/// <summary>
/// Aggiorna i dettagli della subscription
/// </summary>
public async Task<ModuleSubscription> UpdateSubscriptionAsync(
string code,
SubscriptionType? subscriptionType = null,
DateTime? endDate = null,
bool? autoRenew = null,
string? notes = null)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.Subscription == null)
throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription attiva");
if (subscriptionType.HasValue)
module.Subscription.SubscriptionType = subscriptionType.Value;
if (endDate.HasValue)
module.Subscription.EndDate = endDate.Value;
if (autoRenew.HasValue)
module.Subscription.AutoRenew = autoRenew.Value;
if (notes != null)
module.Subscription.Notes = notes;
module.Subscription.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
InvalidateCache();
return module.Subscription;
}
/// <summary>
/// Rinnova una subscription esistente
/// </summary>
public async Task<ModuleSubscription> RenewSubscriptionAsync(string code, decimal? paidPrice = null)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.Subscription == null)
throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription da rinnovare");
var now = DateTime.UtcNow;
var currentEnd = module.Subscription.EndDate ?? now;
var newStart = currentEnd > now ? currentEnd : now;
var newEnd = module.Subscription.SubscriptionType switch
{
SubscriptionType.Monthly => newStart.AddMonths(1),
SubscriptionType.Annual => newStart.AddYears(1),
_ => newStart.AddYears(1) // Default to annual
};
module.Subscription.StartDate = newStart;
module.Subscription.EndDate = newEnd;
module.Subscription.LastRenewalDate = now;
module.Subscription.IsEnabled = true;
module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice;
module.Subscription.UpdatedAt = now;
await _context.SaveChangesAsync();
InvalidateCache();
_logger.LogInformation(
"Modulo {ModuleCode} rinnovato fino a {EndDate}",
code, newEnd);
return module.Subscription;
}
/// <summary>
/// Ottiene tutte le subscription
/// </summary>
public async Task<List<ModuleSubscription>> GetAllSubscriptionsAsync()
{
return await _context.ModuleSubscriptions
.Include(s => s.Module)
.OrderBy(s => s.Module.SortOrder)
.ToListAsync();
}
/// <summary>
/// Verifica e disattiva i moduli con subscription scaduta (per job schedulato)
/// </summary>
public async Task<int> CheckExpiredSubscriptionsAsync()
{
var expiredSubscriptions = await _context.ModuleSubscriptions
.Include(s => s.Module)
.Where(s => s.IsEnabled &&
s.EndDate.HasValue &&
s.EndDate.Value < DateTime.UtcNow &&
!s.AutoRenew)
.ToListAsync();
foreach (var subscription in expiredSubscriptions)
{
subscription.IsEnabled = false;
subscription.UpdatedAt = DateTime.UtcNow;
_logger.LogWarning(
"Modulo {ModuleCode} disattivato per scadenza subscription",
subscription.Module.Code);
}
if (expiredSubscriptions.Any())
{
await _context.SaveChangesAsync();
InvalidateCache();
}
return expiredSubscriptions.Count;
}
/// <summary>
/// Ottiene i moduli in scadenza entro N giorni
/// </summary>
public async Task<List<AppModule>> GetExpiringModulesAsync(int daysThreshold = 30)
{
var thresholdDate = DateTime.UtcNow.AddDays(daysThreshold);
return await _context.AppModules
.Include(m => m.Subscription)
.Where(m => m.Subscription != null &&
m.Subscription.IsEnabled &&
m.Subscription.EndDate.HasValue &&
m.Subscription.EndDate.Value <= thresholdDate &&
m.Subscription.EndDate.Value > DateTime.UtcNow)
.OrderBy(m => m.Subscription!.EndDate)
.ToListAsync();
}
/// <summary>
/// Verifica le dipendenze mancanti per un modulo
/// </summary>
private async Task<List<string>> CheckDependenciesAsync(AppModule module)
{
var dependencies = module.GetDependencies().ToList();
if (!dependencies.Any())
return new List<string>();
var missingDeps = new List<string>();
foreach (var depCode in dependencies)
{
if (!await IsModuleEnabledAsync(depCode))
{
var depModule = await GetModuleByCodeAsync(depCode);
missingDeps.Add(depModule?.Name ?? depCode);
}
}
return missingDeps;
}
/// <summary>
/// Ottiene i moduli che dipendono da un determinato modulo
/// </summary>
private async Task<List<AppModule>> GetDependentModulesAsync(string code)
{
var allModules = await GetAllModulesAsync();
return allModules
.Where(m => m.GetDependencies().Contains(code))
.ToList();
}
/// <summary>
/// Invalida la cache dei moduli
/// </summary>
public void InvalidateCache()
{
_cache.Remove(MODULES_CACHE_KEY);
_cache.Remove(ACTIVE_MODULES_CACHE_KEY);
_logger.LogDebug("Cache moduli invalidata");
}
/// <summary>
/// Inizializza i moduli di default se non esistono
/// </summary>
public async Task SeedDefaultModulesAsync()
{
if (await _context.AppModules.AnyAsync())
return;
var defaultModules = new List<AppModule>
{
new AppModule
{
Code = "warehouse",
Name = "Magazzino",
Description = "Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte",
Icon = "Warehouse",
BasePrice = 1200m,
MonthlyMultiplier = 1.2m,
SortOrder = 10,
IsCore = false,
RoutePath = "/warehouse",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "purchases",
Name = "Acquisti",
Description = "Gestione ordini fornitori, DDT in entrata, fatture passive e analisi acquisti",
Icon = "ShoppingCart",
BasePrice = 1500m,
MonthlyMultiplier = 1.2m,
SortOrder = 20,
IsCore = false,
Dependencies = "warehouse",
RoutePath = "/purchases",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "sales",
Name = "Vendite",
Description = "Gestione ordini clienti, DDT in uscita, fatture attive e analisi vendite",
Icon = "PointOfSale",
BasePrice = 1500m,
MonthlyMultiplier = 1.2m,
SortOrder = 30,
IsCore = false,
Dependencies = "warehouse",
RoutePath = "/sales",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "production",
Name = "Produzione",
Description = "Cicli produttivi, distinte base, pianificazione MRP e controllo avanzamento",
Icon = "Precision Manufacturing",
BasePrice = 2500m,
MonthlyMultiplier = 1.2m,
SortOrder = 40,
IsCore = false,
Dependencies = "warehouse",
RoutePath = "/production",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "quality",
Name = "Qualità",
Description = "Controlli qualità, gestione non conformità, certificazioni e audit",
Icon = "VerifiedUser",
BasePrice = 1800m,
MonthlyMultiplier = 1.2m,
SortOrder = 50,
IsCore = false,
RoutePath = "/quality",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
}
};
_context.AppModules.AddRange(defaultModules);
await _context.SaveChangesAsync();
_logger.LogInformation("Seed {Count} moduli di default completato", defaultModules.Count);
}
}

View File

@@ -0,0 +1,85 @@
namespace Apollinare.Domain.Entities;
/// <summary>
/// Rappresenta un modulo dell'applicazione (es. Magazzino, Acquisti, Vendite).
/// I moduli possono essere attivati/disattivati per gestire licenze e funzionalità.
/// </summary>
public class AppModule : BaseEntity
{
/// <summary>
/// Codice univoco del modulo (es. "warehouse", "purchases", "sales")
/// </summary>
public required string Code { get; set; }
/// <summary>
/// Nome visualizzato del modulo (es. "Magazzino", "Acquisti")
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Descrizione estesa delle funzionalità del modulo
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Nome dell'icona Material UI (es. "Warehouse", "ShoppingCart")
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// Prezzo base annuale del modulo in EUR
/// </summary>
public decimal BasePrice { get; set; }
/// <summary>
/// Moltiplicatore per abbonamento mensile (es. 1.2 = 20% in più rispetto all'annuale/12)
/// </summary>
public decimal MonthlyMultiplier { get; set; } = 1.2m;
/// <summary>
/// Ordine di visualizzazione nel menu (più basso = prima)
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Se true, il modulo fa parte del core e non può essere disattivato
/// </summary>
public bool IsCore { get; set; }
/// <summary>
/// Lista di codici modulo prerequisiti separati da virgola (es. "warehouse,purchases")
/// </summary>
public string? Dependencies { get; set; }
/// <summary>
/// Path base per le route frontend del modulo (es. "/warehouse")
/// </summary>
public string? RoutePath { get; set; }
/// <summary>
/// Se false, il modulo è nascosto e non disponibile per l'acquisto
/// </summary>
public bool IsAvailable { get; set; } = true;
// Navigation property
public ModuleSubscription? Subscription { get; set; }
/// <summary>
/// Restituisce la lista dei codici modulo prerequisiti
/// </summary>
public IEnumerable<string> GetDependencies()
{
if (string.IsNullOrWhiteSpace(Dependencies))
return Enumerable.Empty<string>();
return Dependencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>
/// Calcola il prezzo mensile basato su BasePrice e MonthlyMultiplier
/// </summary>
public decimal GetMonthlyPrice()
{
return Math.Round((BasePrice / 12) * MonthlyMultiplier, 2);
}
}

View File

@@ -0,0 +1,108 @@
namespace Apollinare.Domain.Entities;
/// <summary>
/// Tipo di abbonamento per un modulo
/// </summary>
public enum SubscriptionType
{
/// <summary>Nessun abbonamento attivo</summary>
None = 0,
/// <summary>Abbonamento mensile</summary>
Monthly = 1,
/// <summary>Abbonamento annuale</summary>
Annual = 2
}
/// <summary>
/// Rappresenta lo stato di abbonamento/attivazione di un modulo per questa istanza dell'applicazione.
/// Ogni ModuleSubscription è collegata 1:1 con un AppModule.
/// </summary>
public class ModuleSubscription : BaseEntity
{
/// <summary>
/// ID del modulo associato
/// </summary>
public int ModuleId { get; set; }
/// <summary>
/// Se true, il modulo è attualmente attivo e accessibile
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Tipo di abbonamento corrente
/// </summary>
public SubscriptionType SubscriptionType { get; set; } = SubscriptionType.None;
/// <summary>
/// Data di inizio dell'abbonamento corrente
/// </summary>
public DateTime? StartDate { get; set; }
/// <summary>
/// Data di scadenza dell'abbonamento (null = nessuna scadenza, es. licenza perpetua)
/// </summary>
public DateTime? EndDate { get; set; }
/// <summary>
/// Se true, l'abbonamento si rinnova automaticamente alla scadenza
/// </summary>
public bool AutoRenew { get; set; }
/// <summary>
/// Note aggiuntive sull'abbonamento (es. codice ordine, riferimento contratto)
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// Data dell'ultimo rinnovo effettuato
/// </summary>
public DateTime? LastRenewalDate { get; set; }
/// <summary>
/// Prezzo pagato per l'abbonamento corrente (può differire da BasePrice per sconti)
/// </summary>
public decimal? PaidPrice { get; set; }
// Navigation property
public AppModule Module { get; set; } = null!;
/// <summary>
/// Verifica se l'abbonamento è attualmente valido (attivo e non scaduto)
/// </summary>
public bool IsValid()
{
if (!IsEnabled)
return false;
// Se non c'è data di scadenza, è valido (licenza perpetua o core module)
if (!EndDate.HasValue)
return true;
return EndDate.Value >= DateTime.UtcNow;
}
/// <summary>
/// Calcola i giorni rimanenti alla scadenza (null se nessuna scadenza)
/// </summary>
public int? GetDaysRemaining()
{
if (!EndDate.HasValue)
return null;
var remaining = (EndDate.Value - DateTime.UtcNow).Days;
return remaining < 0 ? 0 : remaining;
}
/// <summary>
/// Verifica se l'abbonamento sta per scadere (entro i prossimi N giorni)
/// </summary>
public bool IsExpiringSoon(int daysThreshold = 30)
{
if (!EndDate.HasValue)
return false;
var daysRemaining = GetDaysRemaining();
return daysRemaining.HasValue && daysRemaining.Value <= daysThreshold && daysRemaining.Value > 0;
}
}

View File

@@ -36,6 +36,10 @@ public class AppollinareDbContext : DbContext
public DbSet<ReportImage> ReportImages => Set<ReportImage>();
public DbSet<VirtualDataset> VirtualDatasets => Set<VirtualDataset>();
// Module system entities
public DbSet<AppModule> AppModules => Set<AppModule>();
public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -225,5 +229,32 @@ public class AppollinareDbContext : DbContext
entity.HasIndex(e => e.Nome).IsUnique();
entity.HasIndex(e => e.Categoria);
});
// AppModule
modelBuilder.Entity<AppModule>(entity =>
{
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.SortOrder);
entity.Property(e => e.BasePrice)
.HasPrecision(18, 2);
entity.Property(e => e.MonthlyMultiplier)
.HasPrecision(5, 2);
});
// ModuleSubscription
modelBuilder.Entity<ModuleSubscription>(entity =>
{
entity.HasIndex(e => e.ModuleId).IsUnique();
entity.Property(e => e.PaidPrice)
.HasPrecision(18, 2);
entity.HasOne(e => e.Module)
.WithOne(m => m.Subscription)
.HasForeignKey<ModuleSubscription>(e => e.ModuleId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}