(Falt("Kollektivavtal", "AB").Trim(), true, out var avtal)
+ ? avtal
+ : _valdAnstallning?.Kollektivavtal ?? CollectiveAgreementType.AB;
+
+ if (!decimal.TryParse(Falt("Manadslon", "0").Replace(" ", "").Replace("kr", ""),
+ NumberStyles.Number, CultureInfo.InvariantCulture, out var manadslon) || manadslon <= 0)
+ manadslon = _valdAnstallning?.Manadslon.Amount ?? 0m;
+
+ if (!decimal.TryParse(Falt("Sysselsattningsgrad", "100").Replace("%", "").Replace(",", "."),
+ NumberStyles.Number, CultureInfo.InvariantCulture, out var grad) || grad <= 0)
+ grad = _valdAnstallning?.Sysselsattningsgrad.Value ?? 100m;
+
+ var tilltrade = DateOnly.TryParse(Falt("Startdatum", ""), out var start)
+ ? start
+ : _valdAnstallning?.Giltighetsperiod.Start ?? DateOnly.FromDateTime(DateTime.Today);
+ DateOnly? slutdatum = DateOnly.TryParse(Falt("Slutdatum", ""), out var slut)
+ ? slut
+ : _valdAnstallning?.Giltighetsperiod.End;
+
+ return new AnstallningsavtalUppgifter(
+ ArbetsgivareNamn: Falt("Arbetsgivare", PdfGenerator.ArbetsgivareNamn),
+ ArbetsgivareAdress: PdfGenerator.ArbetsgivareAdress,
+ ArbetstagareNamn: FulltNamnFranFalt(),
+ ArbetstagarePersonnummerMaskerat: _valdAnstalld?.Personnummer.ToMaskedString() ?? Falt("Personnummer", "—"),
+ EnhetNamn: Falt("Enhet", "—"),
+ Arbetsplats: Falt("Arbetsgivare", PdfGenerator.ArbetsgivareNamn),
+ Befattningstitel: _fieldValues.GetValueOrDefault("Befattning") is { Length: > 0 } b ? b : null,
+ Anstallningsform: anstallningsform,
+ Manadslon: manadslon,
+ Sysselsattningsgrad: grad,
+ Tilltradesdag: tilltrade,
+ Slutdatum: slutdatum,
+ Kollektivavtal: kollektivavtal,
+ KollektivavtalNamn: KollektivavtalNamn(kollektivavtal));
+ }
+
+ private static string? KollektivavtalNamn(CollectiveAgreementType avtal) => avtal switch
+ {
+ CollectiveAgreementType.AB => "Allmänna bestämmelser (AB)",
+ CollectiveAgreementType.HOK => "Huvudöverenskommelse om lön och villkor (HÖK)",
+ CollectiveAgreementType.MBA => "Medbestämmandeavtal (MBA)",
+ CollectiveAgreementType.PAN => "PAN",
+ _ => null
+ };
+
private async Task SkrivUt()
{
await JS.InvokeVoidAsync("window.print");
@@ -125,8 +272,8 @@
private async Task LaddaNer()
{
- var bytes = System.Text.Encoding.UTF8.GetBytes(_pdfPreview);
- var base64 = Convert.ToBase64String(bytes);
- await JS.InvokeVoidAsync("downloadFile", $"{_mallNamn}.txt", base64, "text/plain");
+ if (_pdfBytes.Length == 0) return;
+ var base64 = Convert.ToBase64String(_pdfBytes);
+ await JS.InvokeVoidAsync("downloadFile", $"{_mallNamn}.pdf", base64, "application/pdf");
}
}
diff --git a/src/Web/Components/Pages/Formaner/Friskvard.razor b/src/Web/Components/Pages/Formaner/Friskvard.razor
index 49a62b9..7eab128 100644
--- a/src/Web/Components/Pages/Formaner/Friskvard.razor
+++ b/src/Web/Components/Pages/Formaner/Friskvard.razor
@@ -76,7 +76,10 @@ else
| @c.Claim.Datum.ToString("yyyy-MM-dd") |
@StatusText(c.Claim.Status) |
- @if (c.Claim.Status == WellnessClaimStatus.Inskickad && Auth.EmployeeId.HasValue)
+ @* Godkännande kräver chefs-/HR-behörighet och får inte avse egen ansökan
+ (jämför FKAnmalan-sidans HR-spärr). *@
+ @if (c.Claim.Status == WellnessClaimStatus.Inskickad && Auth.EmployeeId.HasValue
+ && Auth.IsChef && c.Claim.AnstallId != Auth.EmployeeId.Value)
{
Godkänn
}
@@ -119,7 +122,10 @@ else
_saving = true; _error = null;
try
{
- var claim = WellnessClaim.Skapa(Auth.EmployeeId.Value, _formAktivitet, _formBelopp, DateOnly.FromDateTime(_formDatum!.Value));
+ var datum = DateOnly.FromDateTime(_formDatum!.Value);
+ var tidigareGodkant = await SummaGodkantUnderAretAsync(db, Auth.EmployeeId.Value, datum.Year);
+ var claim = WellnessClaim.Skapa(Auth.EmployeeId.Value, _formAktivitet, _formBelopp, datum,
+ tidigareGodkantUnderAret: tidigareGodkant);
db.WellnessClaims.Add(claim);
await db.SaveChangesAsync();
_claims.Insert(0, new ClaimVm(Auth.UserName ?? "Jag", claim));
@@ -131,12 +137,45 @@ else
private async Task GodkannClaim(ClaimVm vm)
{
- await using var db = await DbFactory.CreateDbContextAsync();
if (!Auth.EmployeeId.HasValue) { _error = "Kräver kopplad anställd."; return; }
- try { vm.Claim.Godkann(Auth.EmployeeId.Value); await db.SaveChangesAsync(); _success = "Ansökan godkänd."; }
+ // Behörighetsspärr (jämför FKAnmalan): endast chef/HR/Admin får godkänna,
+ // och aldrig sin egen ansökan.
+ if (!Auth.IsChef) { _error = "Endast chef eller HR kan godkänna friskvårdsansökningar."; return; }
+ if (vm.Claim.AnstallId == Auth.EmployeeId.Value) { _error = "Du kan inte godkänna din egen ansökan."; return; }
+ _error = null;
+ await using var db = await DbFactory.CreateDbContextAsync();
+ try
+ {
+ // Hämta om entiteten i samma context som SaveChanges — den listade instansen
+ // kommer från en annan (disposad) context och trackas inte här.
+ var claim = await db.WellnessClaims.FirstOrDefaultAsync(c => c.Id == vm.Claim.Id);
+ if (claim is null) { _error = "Ansökan hittades inte."; return; }
+
+ var tidigareGodkant = await SummaGodkantUnderAretAsync(db, claim.AnstallId, claim.Datum.Year, claim.Id);
+ claim.Godkann(Auth.EmployeeId.Value, tidigareGodkantUnderAret: tidigareGodkant);
+ await db.SaveChangesAsync();
+
+ var idx = _claims.IndexOf(vm);
+ if (idx >= 0) _claims[idx] = vm with { Claim = claim };
+ _success = "Ansökan godkänd.";
+ }
catch (Exception ex) { _error = $"Fel: {ex.Message}"; }
}
+ /// Summan av årets redan godkända friskvårdsansökningar för den anställde.
+ private static async Task SummaGodkantUnderAretAsync(
+ RegionHRDbContext db, Guid anstallId, int ar, Guid? exkluderaClaimId = null)
+ {
+ var arStart = new DateOnly(ar, 1, 1);
+ var arSlut = new DateOnly(ar, 12, 31);
+ return await db.WellnessClaims
+ .Where(c => c.AnstallId == anstallId
+ && c.Status == WellnessClaimStatus.Godkand
+ && c.Datum >= arStart && c.Datum <= arSlut
+ && (exkluderaClaimId == null || c.Id != exkluderaClaimId))
+ .SumAsync(c => c.Belopp);
+ }
+
private static string StatusText(WellnessClaimStatus s) => s switch { WellnessClaimStatus.Inskickad => "Inskickad", WellnessClaimStatus.Godkand => "Godkänd", WellnessClaimStatus.Avslagen => "Avslagen", _ => s.ToString() };
private static Color StatusFarg(WellnessClaimStatus s) => s switch { WellnessClaimStatus.Inskickad => Color.Warning, WellnessClaimStatus.Godkand => Color.Success, WellnessClaimStatus.Avslagen => Color.Error, _ => Color.Default };
private record ClaimVm(string Namn, WellnessClaim Claim);
diff --git a/src/Web/Components/Pages/HalsoSAM/Index.razor b/src/Web/Components/Pages/HalsoSAM/Index.razor
index d0872a1..24d6ee5 100644
--- a/src/Web/Components/Pages/HalsoSAM/Index.razor
+++ b/src/Web/Components/Pages/HalsoSAM/Index.razor
@@ -4,6 +4,7 @@
@using RegionHR.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory DbFactory
+@inject RegionHR.Web.Services.UnitScopeService Scope
OpenHR — HälsoSAM
@@ -197,6 +198,19 @@ else
try
{
_cases = await db.RehabCases.Include(c => c.Uppfoljningar).ToListAsync();
+
+ // Enhets-scoping (samma mönster som Chef/Index.razor): en chef ser
+ // bara sin egen enhets rehabärenden; HR/Admin ser allt. Fail-closed:
+ // kan enheten inte härledas för en chef visas inga ärenden i stället
+ // för hela organisationen (rehabdata är extra känslig persondata).
+ if (Scope.IsUnitScoped)
+ {
+ var scopeIds = await Scope.GetEmployeeIdsInScopeAsync();
+ _cases = scopeIds is null
+ ? []
+ : _cases.Where(c => scopeIds.Contains(c.AnstallId.Value)).ToList();
+ }
+
var employees = await db.Employees.ToListAsync();
_empNames = employees.ToDictionary(
e => e.Id,
diff --git a/src/Web/Components/Pages/Kompetens/Index.razor b/src/Web/Components/Pages/Kompetens/Index.razor
index 14cab48..b09765b 100644
--- a/src/Web/Components/Pages/Kompetens/Index.razor
+++ b/src/Web/Components/Pages/Kompetens/Index.razor
@@ -32,6 +32,11 @@
Kompetens skapad!
}
+@if (_skapadFel != null)
+{
+ @_skapadFel
+}
+
@* --- Expiry alert banner --- *@
@if (_expiringCount > 0)
{
@@ -134,17 +139,73 @@
@code {
private bool _showForm;
private bool _skapad;
+ private string? _skapadFel;
private string _newNamn = "";
private string _newAnstelld = "";
private string _newGiltigTill = "";
- private void SkapaKompetens()
+ private async Task SkapaKompetens()
{
- _showForm = false;
- _skapad = true;
- _newNamn = "";
- _newAnstelld = "";
- _newGiltigTill = "";
+ _skapadFel = null;
+ if (string.IsNullOrWhiteSpace(_newNamn))
+ {
+ _skapadFel = "Ange ett namn på kompetensen.";
+ return;
+ }
+
+ using var db = DbFactory.CreateDbContext();
+ try
+ {
+ // Resolvera anställd: GUID direkt, annars namnsökning.
+ Guid anstallId;
+ var anstalldInput = _newAnstelld.Trim();
+ if (Guid.TryParse(anstalldInput, out var direktId))
+ {
+ anstallId = direktId;
+ }
+ else
+ {
+ var kandidater = await db.Employees
+ .Select(e => new { e.Id, e.Fornamn, e.Efternamn })
+ .ToListAsync();
+ var traff = anstalldInput.Length == 0 ? null : kandidater.FirstOrDefault(k =>
+ $"{k.Fornamn} {k.Efternamn}".Contains(anstalldInput, StringComparison.OrdinalIgnoreCase));
+ if (traff is null)
+ {
+ _skapadFel = "Hittade ingen anställd som matchar. Ange namn eller anställnings-ID (GUID).";
+ return;
+ }
+ anstallId = traff.Id.Value;
+ }
+
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ DateOnly? giltigTill = DateOnly.TryParse(_newGiltigTill, out var datum) ? datum : null;
+
+ var cert = Certification.Skapa(
+ anstallId, _newNamn.Trim(), CertificationType.Certifikat,
+ utfardare: null, giltigFran: today, giltigTill: giltigTill);
+ db.Certifications.Add(cert);
+ await db.SaveChangesAsync();
+
+ _certs.Insert(0, cert);
+ if (cert.GiltigTill.HasValue &&
+ cert.GiltigTill.Value >= today &&
+ cert.GiltigTill.Value <= today.AddDays(90))
+ {
+ _expiringCerts = _expiringCerts.Append(cert).OrderBy(c => c.GiltigTill).ToList();
+ _expiringCount = _expiringCerts.Count;
+ }
+
+ _showForm = false;
+ _skapad = true;
+ _newNamn = "";
+ _newAnstelld = "";
+ _newGiltigTill = "";
+ }
+ catch (Exception ex)
+ {
+ _skapadFel = $"Kunde inte spara kompetensen: {ex.Message}";
+ }
}
private List _certs = [];
diff --git a/src/Web/Components/Pages/Lon/KorningDetalj.razor b/src/Web/Components/Pages/Lon/KorningDetalj.razor
index f6e75f9..73d8d31 100644
--- a/src/Web/Components/Pages/Lon/KorningDetalj.razor
+++ b/src/Web/Components/Pages/Lon/KorningDetalj.razor
@@ -199,7 +199,28 @@ else
.ToList()))
.ToList();
- BuildAnomalies(run, empNames);
+ // Baslinje för anomalisignalen: den anställdes egen föregående period.
+ var foregaendeBrutto = new Dictionary();
+ if (!run.ArRetroaktiv)
+ {
+ var foregaende = await db.PayrollRuns
+ .Include(r => r.Resultat)
+ .Where(r => !r.ArRetroaktiv
+ && (r.Year < run.Year || (r.Year == run.Year && r.Month < run.Month)))
+ .OrderByDescending(r => r.Year)
+ .ThenByDescending(r => r.Month)
+ .ThenByDescending(r => r.StartadVid)
+ .FirstOrDefaultAsync();
+
+ if (foregaende is not null)
+ {
+ foregaendeBrutto = foregaende.Resultat
+ .GroupBy(r => r.AnstallId.Value)
+ .ToDictionary(g => g.Key, g => g.First().Brutto.Amount);
+ }
+ }
+
+ BuildAnomalies(run, empNames, foregaendeBrutto);
}
catch (Exception ex) { _error = $"Kunde inte hämta lönekörning: {ex.Message}"; }
finally { _loading = false; }
@@ -211,29 +232,40 @@ else
_expanded.Remove(index);
}
- private void BuildAnomalies(PayrollRun run, Dictionary empNames)
+ private void BuildAnomalies(PayrollRun run, Dictionary empNames, Dictionary foregaendeBrutto)
{
- if (run.Resultat.Count == 0) return;
+ // Retroaktiva körningar innehåller differensposter — de avviker per definition
+ // och ska inte flaggas mot en baslinje.
+ if (run.ArRetroaktiv || run.Resultat.Count == 0) return;
- var avgBrutto = run.TotalBrutto.Amount / Math.Max(run.AntalAnstallda, 1);
- var threshold = avgBrutto * 0.10m;
+ // Baslinje per anställd: egen brutto föregående period. För anställda utan
+ // historik används förväntad grundlön (månadslön × sysselsättningsgrad).
+ // Jämförelse mot körningens genomsnitt flaggade nästan alla (alla löner
+ // skiljer sig från snittet) — därför jämförs mot individens egen nivå,
+ // med ett band på ±20 %.
+ const decimal band = 0.20m;
- var anomalousResults = run.Resultat
- .Where(r => Math.Abs(r.Brutto.Amount - avgBrutto) > threshold)
- .Take(10)
- .ToList();
-
- foreach (var r in anomalousResults)
+ foreach (var r in run.Resultat)
{
+ var harHistorik = foregaendeBrutto.TryGetValue(r.AnstallId.Value, out var baslinje);
+ if (!harHistorik)
+ baslinje = r.Manadslon.Amount * r.Sysselsattningsgrad / 100m;
+ if (baslinje <= 0m) continue;
+
+ var diff = r.Brutto.Amount - baslinje;
+ var pct = Math.Abs(diff / baslinje * 100m);
+ if (pct <= band * 100m) continue;
+
var namn = empNames.GetValueOrDefault(r.AnstallId.Value, "Okänd");
- var diff = r.Brutto.Amount - avgBrutto;
- var pct = avgBrutto > 0 ? Math.Abs(diff / avgBrutto * 100) : 0;
- var direction = diff > 0 ? "+" : "";
+ var direction = diff > 0 ? "+" : "-";
+ var jamforelse = harHistorik ? "föreg. period" : "förväntad grundlön";
_anomalier.Add(new Anomali(
namn,
- $"{r.Brutto.Amount:N0} kr (snitt {avgBrutto:N0} kr, {direction}{pct:F0}%)",
+ $"{r.Brutto.Amount:N0} kr ({jamforelse} {baslinje:N0} kr, {direction}{pct:F0}%)",
SummarizeDeviations(r)));
+
+ if (_anomalier.Count >= 10) break;
}
}
diff --git a/src/Web/Components/Pages/Loneoversyn/Detalj.razor b/src/Web/Components/Pages/Loneoversyn/Detalj.razor
index c67eb0e..7fdc5fa 100644
--- a/src/Web/Components/Pages/Loneoversyn/Detalj.razor
+++ b/src/Web/Components/Pages/Loneoversyn/Detalj.razor
@@ -4,11 +4,13 @@
@using RegionHR.SalaryReview.Domain
@using RegionHR.SharedKernel.Domain
@using RegionHR.Web.Services
+@using RegionHR.Infrastructure.Payroll
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory DbFactory
@inject NavigationManager Nav
@inject AuthService Auth
@inject ISnackbar Snackbar
+@inject PayrollBatchService PayrollBatch
OpenHR - Löneöversyn
@@ -56,12 +58,16 @@ else
-
+
-
+
+ @if (ArLonesankning)
+ {
+ Föreslagen lön är lägre än nuvarande lön — lönesänkningar kan inte läggas till i en löneöversyn.
+ }
Lägg till förslag
+ Disabled="@(_valdKandidat is null || _nyLon <= 0 || ArLonesankning || string.IsNullOrWhiteSpace(_motivering) || _busy)">Lägg till förslag
}
@@ -150,7 +156,7 @@ else
{
Genomförd @(_runda.GenomfordDatum?.ToString("yyyy-MM-dd") ?? "-"). Ny lön applicerad på @AntalGodkanda anställning(ar).
- Total retroaktiv efterbetalning: @TotalRetro.ToString("N0") kr (betalas ut i nästa lönekörning).
+ Total retroaktiv efterbetalning: @TotalRetro.ToString("N0") kr (utbetalas via den retroaktiva lönekörning som skapades vid genomförandet — se Lön → Lönekörningar).
}
}
@@ -174,11 +180,12 @@ else
private string _valdNuvarandeLonText => _valdKandidat is null ? "-" : $"{_valdKandidat.NuvarandeLon.Amount:N0} kr";
private string OkningPreview => _valdKandidat is null ? "-" : $"{(_nyLon - _valdKandidat.NuvarandeLon.Amount):N0} kr";
+ private bool ArLonesankning => _valdKandidat is not null && _nyLon > 0 && _nyLon < _valdKandidat.NuvarandeLon.Amount;
private int AntalAktiva => _runda?.Forslag.Count(f => f.Status != SalaryProposalStatus.Avslagen) ?? 0;
private int AntalGodkanda => _runda?.Forslag.Count(f => f.Status == SalaryProposalStatus.Godkand) ?? 0;
private decimal TotalRetro => _runda?.Forslag.Sum(f => f.RetroaktivtBelopp?.Amount ?? 0m) ?? 0m;
- protected override void OnInitialized() => _svc = new LoneoversynService(DbFactory);
+ protected override void OnInitialized() => _svc = new LoneoversynService(DbFactory, PayrollBatch);
protected override async Task OnParametersSetAsync() => await LaddaAsync();
@@ -266,6 +273,10 @@ else
var resultat = await _svc.GenomforAsync(RundaId, genomfordAv);
_info = $"Löneöversyn genomförd. Ny lön applicerad på {resultat.AntalAnstallda} anställning(ar), " +
$"total retroaktiv efterbetalning {resultat.TotalRetroaktivt.Amount:N0} kr.";
+ if (resultat.RetroaktivaKorningar.Count > 0)
+ _info += $" Retroaktiv lönekörning skapad för {resultat.RetroaktivaKorningar.Count} period(er): {string.Join(", ", resultat.RetroaktivaKorningar)}.";
+ if (resultat.RetroaktivaFel.Count > 0)
+ _error = "Retroaktiv lönekörning kunde inte skapas för: " + string.Join("; ", resultat.RetroaktivaFel);
Snackbar.Add(_info, Severity.Success);
});
diff --git a/src/Web/Components/Pages/Loneoversyn/Index.razor b/src/Web/Components/Pages/Loneoversyn/Index.razor
index 909ccbc..bca396b 100644
--- a/src/Web/Components/Pages/Loneoversyn/Index.razor
+++ b/src/Web/Components/Pages/Loneoversyn/Index.razor
@@ -25,7 +25,7 @@ else
-
+
@@ -33,9 +33,10 @@ else
+
Skapa
+ Disabled="@(string.IsNullOrWhiteSpace(_formNamn) || _formIkrafttradande is null || _saving)">Skapa
}
@@ -72,6 +73,7 @@ else
private int _formAr = 2026;
private CollectiveAgreementType _formAvtal = CollectiveAgreementType.AB;
private decimal _formBudget = 10000000m;
+ private DateTime? _formIkrafttradande = DateTime.Today.AddMonths(2);
protected override async Task OnInitializedAsync()
{
@@ -86,11 +88,11 @@ else
private async Task SkapaRunda()
{
await using var db = await DbFactory.CreateDbContextAsync();
- if (string.IsNullOrWhiteSpace(_formNamn)) return;
+ if (string.IsNullOrWhiteSpace(_formNamn) || _formIkrafttradande is null) return;
_saving = true; _error = null;
try
{
- var runda = SalaryReviewRound.Skapa(_formNamn!, _formAr, _formAvtal, Money.SEK(_formBudget), DateOnly.FromDateTime(DateTime.Today.AddMonths(2)));
+ var runda = SalaryReviewRound.Skapa(_formNamn!, _formAr, _formAvtal, Money.SEK(_formBudget), DateOnly.FromDateTime(_formIkrafttradande.Value));
db.SalaryReviewRounds.Add(runda);
await db.SaveChangesAsync();
_rundor.Insert(0, runda);
diff --git a/src/Web/Components/Pages/MinSida/Sjukanmalan.razor b/src/Web/Components/Pages/MinSida/Sjukanmalan.razor
index 066c6c4..0cc56eb 100644
--- a/src/Web/Components/Pages/MinSida/Sjukanmalan.razor
+++ b/src/Web/Components/Pages/MinSida/Sjukanmalan.razor
@@ -15,6 +15,34 @@
@_error
}
+@if (_friskanmald)
+{
+
+ Friskanmälan registrerad — välkommen tillbaka!
+
+}
+
+@* === FRISKANMÄLAN: pågående sjukfall kan avslutas här === *@
+@if (_pagaendeSjukfall is not null)
+{
+
+
+ Du är sjukanmäld sedan @_pagaendeSjukfall.StartDatum.ToString("d MMMM")
+
+
+ Är du tillbaka på jobbet? Friskanmäl dig här så avslutas sjukfallet.
+
+
+
+ Friskanmäl mig
+
+
+}
+
@switch (_currentStep)
{
@@ -119,6 +147,43 @@
private DateTime? _customDate;
private DateTime? _endDate;
private string? _error;
+ private SickLeaveNotification? _pagaendeSjukfall;
+ private DateTime? _sistaSjukdag;
+ private bool _friskanmald;
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!Auth.EmployeeId.HasValue) return;
+ try
+ {
+ await using var db = await DbFactory.CreateDbContextAsync();
+ _pagaendeSjukfall = await db.SickLeaveNotifications
+ .Where(s => s.AnstallId == Auth.EmployeeId.Value && s.SlutDatum == null)
+ .OrderByDescending(s => s.StartDatum)
+ .FirstOrDefaultAsync();
+ _sistaSjukdag = DateTime.Today;
+ }
+ catch (Exception ex) { _error = $"Kunde inte hämta pågående sjukanmälan: {ex.Message}"; }
+ }
+
+ private async Task Friskanmal()
+ {
+ if (_pagaendeSjukfall is null || !_sistaSjukdag.HasValue) return;
+ _error = null;
+ try
+ {
+ await using var db = await DbFactory.CreateDbContextAsync();
+ var sjukfall = await db.SickLeaveNotifications
+ .FirstOrDefaultAsync(s => s.Id == _pagaendeSjukfall.Id);
+ if (sjukfall is null) { _error = "Sjukanmälan hittades inte."; return; }
+
+ sjukfall.Avsluta(DateOnly.FromDateTime(_sistaSjukdag.Value));
+ await db.SaveChangesAsync();
+ _pagaendeSjukfall = null;
+ _friskanmald = true;
+ }
+ catch (Exception ex) { _error = $"Kunde inte friskanmäla: {ex.Message}"; }
+ }
private void SelectStartDate(DateOnly date)
{
@@ -158,6 +223,9 @@
var anstallId = Auth.EmployeeId.Value;
var notification = SickLeaveNotification.Skapa(anstallId, _startDate);
+ // Retroaktiv anmälan (t.ex. "Igår"): räkna fram korrekt sjukdag direkt
+ // så att läkarintygs-/FK-flaggorna inte släpar tills det dagliga jobbet kört.
+ notification.SynkroniseraDag(DateOnly.FromDateTime(DateTime.Today));
await db.SickLeaveNotifications.AddAsync(notification);
var request = LeaveRequest.Skapa(
diff --git a/src/Web/Components/Pages/Schema/OppnaPassDetalj.razor b/src/Web/Components/Pages/Schema/OppnaPassDetalj.razor
index 552ea9b..1021bfd 100644
--- a/src/Web/Components/Pages/Schema/OppnaPassDetalj.razor
+++ b/src/Web/Components/Pages/Schema/OppnaPassDetalj.razor
@@ -147,12 +147,20 @@ else
private async Task TilldelaPass()
{
- await using var db = await DbFactory.CreateDbContextAsync();
if (_shift == null) return;
try
{
- var bud = _shift.Bud.Where(b => b.Status == ShiftBidStatus.Pending).ToList();
+ // Ladda passet i SAMMA context som SaveChanges — det fält-cachade _shift
+ // laddades i en redan disposad context och är detached, så ändringar på
+ // det persisteras aldrig.
+ await using var db = await DbFactory.CreateDbContextAsync();
+ var shift = await db.OpenShifts
+ .Include(s => s.Bud)
+ .FirstOrDefaultAsync(s => s.Id == Id);
+ if (shift == null) { _actionMsg = "Passet hittades inte."; _actionSev = Severity.Error; return; }
+
+ var bud = shift.Bud.Where(b => b.Status == ShiftBidStatus.Pending).ToList();
var anstallIds = bud.Select(b => b.AnstallId).ToList();
var employments = await db.Employments
@@ -164,7 +172,7 @@ else
.ToListAsync();
var scheduledShifts = await db.ScheduledShifts
- .Where(s => anstallIds.Contains(s.AnstallId) && s.Datum == _shift.Datum)
+ .Where(s => anstallIds.Contains(s.AnstallId) && s.Datum == shift.Datum)
.ToListAsync();
var anstallda = anstallIds.Select(aId =>
@@ -186,7 +194,7 @@ else
}).ToList();
var assigner = new ShiftBidAssigner();
- var result = assigner.Tilldela(_shift, bud, _valdMetod, anstallda);
+ var result = assigner.Tilldela(shift, bud, _valdMetod, anstallda);
if (!result.Success)
{
@@ -197,8 +205,35 @@ else
if (result.BidResult is not null)
await db.ShiftBidResults.AddAsync(result.BidResult);
+ // Skapa schemapass för vinnaren i det schema på enheten som täcker datumet.
+ var schemaPassSkapat = false;
+ if (result.VinnareId.HasValue)
+ {
+ var enhetsScheman = await db.Schedules
+ .Where(s => s.EnhetId == shift.EnhetId)
+ .ToListAsync();
+ var schema = enhetsScheman
+ .Where(s => s.Status != ScheduleStatus.Arkiverad && s.Period.Contains(shift.Datum))
+ .OrderByDescending(s => s.Status == ScheduleStatus.Publicerad)
+ .ThenByDescending(s => s.CreatedAt)
+ .FirstOrDefault();
+
+ if (schema is not null)
+ {
+ var passTyp = Enum.TryParse(shift.PassTyp, ignoreCase: true, out var typ)
+ ? typ : ShiftType.Dag;
+ schema.LaggTillPass(
+ result.VinnareId.Value, shift.Datum, passTyp,
+ shift.StartTid, shift.SlutTid, TimeSpan.Zero);
+ schemaPassSkapat = true;
+ }
+ }
+
await db.SaveChangesAsync();
- _actionMsg = $"Pass tilldelat! Metod: {result.Metod}. {result.Motivering}";
+ _actionMsg = $"Pass tilldelat! Metod: {result.Metod}. {result.Motivering}"
+ + (schemaPassSkapat
+ ? " Schemapass skapat för vinnaren."
+ : " Obs: inget aktivt schema på enheten täcker datumet — schemapass kunde inte skapas.");
_actionSev = Severity.Success;
// Refresh
diff --git a/src/Web/Components/Pages/Schema/Passbyte.razor b/src/Web/Components/Pages/Schema/Passbyte.razor
index 6650e14..37c8912 100644
--- a/src/Web/Components/Pages/Schema/Passbyte.razor
+++ b/src/Web/Components/Pages/Schema/Passbyte.razor
@@ -87,25 +87,60 @@ else
private async Task GodkannSwap(SwapVm vm)
{
- await using var db = await DbFactory.CreateDbContextAsync();
if (!Auth.EmployeeId.HasValue) { _error = "Kräver kopplad anställd."; return; }
try
{
- vm.Entity.Godkann(Auth.UserName ?? "Chef");
+ // Ladda och mutera i SAMMA context som SaveChanges — annars är entiteten
+ // detached (laddad i en redan disposad context) och inget persisteras.
+ await using var db = await DbFactory.CreateDbContextAsync();
+ var swapId = vm.Entity.Id;
+ var swap = await db.ShiftSwapRequests.FirstOrDefaultAsync(s => s.Id == swapId);
+ if (swap is null) { _actionMsg = "Passbytet hittades inte."; _actionSev = Severity.Error; return; }
+
+ swap.Godkann(Auth.UserName ?? "Chef");
+
+ // Verkställ bytet i schemat: det ursprungliga passet övergår till den som
+ // accepterat, och ett eventuellt ersättningspass övergår till den som begärde.
+ if (swap.ErbjodsAv.HasValue)
+ {
+ var ursprungligtPass = await db.ScheduledShifts
+ .FirstOrDefaultAsync(p => p.Id == swap.UrsprungligtPassId);
+ if (ursprungligtPass is not null)
+ ursprungligtPass.AnstallId = swap.ErbjodsAv.Value;
+ }
+ if (swap.ErsattningsPassId.HasValue)
+ {
+ var ersattningsPass = await db.ScheduledShifts
+ .FirstOrDefaultAsync(p => p.Id == swap.ErsattningsPassId.Value);
+ if (ersattningsPass is not null)
+ ersattningsPass.AnstallId = swap.BegardAv;
+ }
+
await db.SaveChangesAsync();
- _actionMsg = $"Passbyte godkänt."; _actionSev = Severity.Success;
+
+ var idx = _rader.IndexOf(vm);
+ if (idx >= 0) _rader[idx] = vm with { Status = swap.Status, Entity = swap };
+ _actionMsg = $"Passbyte godkänt — schemapasset har flyttats."; _actionSev = Severity.Success;
}
catch (Exception ex) { _actionMsg = $"Fel: {ex.Message}"; _actionSev = Severity.Error; }
}
private async Task NekaSwap(SwapVm vm)
{
- await using var db = await DbFactory.CreateDbContextAsync();
if (!Auth.EmployeeId.HasValue) { _error = "Kräver kopplad anställd."; return; }
try
{
- vm.Entity.Avvisa(Auth.UserName ?? "Chef", "Nekat av chef");
+ // Ladda och mutera i SAMMA context som SaveChanges — annars persisteras inget.
+ await using var db = await DbFactory.CreateDbContextAsync();
+ var swapId = vm.Entity.Id;
+ var swap = await db.ShiftSwapRequests.FirstOrDefaultAsync(s => s.Id == swapId);
+ if (swap is null) { _actionMsg = "Passbytet hittades inte."; _actionSev = Severity.Error; return; }
+
+ swap.Avvisa(Auth.UserName ?? "Chef", "Nekat av chef");
await db.SaveChangesAsync();
+
+ var idx = _rader.IndexOf(vm);
+ if (idx >= 0) _rader[idx] = vm with { Status = swap.Status, Entity = swap };
_actionMsg = $"Passbyte nekat."; _actionSev = Severity.Warning;
}
catch (Exception ex) { _actionMsg = $"Fel: {ex.Message}"; _actionSev = Severity.Error; }
diff --git a/src/Web/Components/Pages/Tidrapporter/Detail.razor b/src/Web/Components/Pages/Tidrapporter/Detail.razor
index 3532019..15d6f92 100644
--- a/src/Web/Components/Pages/Tidrapporter/Detail.razor
+++ b/src/Web/Components/Pages/Tidrapporter/Detail.razor
@@ -130,7 +130,9 @@ else
_ts = await db.Timesheets.FirstOrDefaultAsync(t => t.Id == Id);
if (_ts != null)
{
- var emp = await db.Employees.FirstOrDefaultAsync(e => e.Id.Value == _ts.AnstallId);
+ // Jämför på det starkt typade Id:t — '.Id.Value' kan inte översättas av EF Core.
+ var anstallId = EmployeeId.From(_ts.AnstallId);
+ var emp = await db.Employees.FirstOrDefaultAsync(e => e.Id == anstallId);
_empName = emp != null ? $"{emp.Fornamn} {emp.Efternamn}" : "Okänd";
}
}
diff --git a/src/Web/Components/Pages/Utbildning/Index.razor b/src/Web/Components/Pages/Utbildning/Index.razor
index 45f36eb..9856d6f 100644
--- a/src/Web/Components/Pages/Utbildning/Index.razor
+++ b/src/Web/Components/Pages/Utbildning/Index.razor
@@ -85,12 +85,34 @@
private string _newKursnamn = "";
private string _newBeskrivning = "";
- private void SkapaUtbildning()
+ private async Task SkapaUtbildning()
{
- _showForm = false;
- _skapad = true;
- _newKursnamn = "";
- _newBeskrivning = "";
+ if (string.IsNullOrWhiteSpace(_newKursnamn))
+ {
+ _error = "Ange ett kursnamn.";
+ return;
+ }
+
+ using var db = DbFactory.CreateDbContext();
+ try
+ {
+ var kurs = Course.Skapa(
+ _newKursnamn.Trim(), _newBeskrivning.Trim(),
+ CourseFormat.Klassrum, langdMinuter: 60, obligatorisk: false);
+ db.Courses.Add(kurs);
+ await db.SaveChangesAsync();
+
+ _courses.Insert(0, kurs); // Listan är sorterad på SkapadVid fallande.
+ _error = null;
+ _showForm = false;
+ _skapad = true;
+ _newKursnamn = "";
+ _newBeskrivning = "";
+ }
+ catch (Exception ex)
+ {
+ _error = $"Kunde inte spara utbildningen: {ex.Message}";
+ }
}
private List _courses = [];
diff --git a/src/Web/Program.cs b/src/Web/Program.cs
index fff7923..f1d1aa8 100644
--- a/src/Web/Program.cs
+++ b/src/Web/Program.cs
@@ -12,6 +12,7 @@
using Microsoft.Identity.Web;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.HttpOverrides;
// Npgsql 6+ mappar timestamptz strikt till UTC. Domänen sätter på många ställen
// DateTime med Kind=Unspecified (utvecklat mot InMemory som saknar denna kontroll).
@@ -99,6 +100,11 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// Granskningslogg: AuditInterceptor (scoped, se Infrastructure.DependencyInjection)
+// stämplar poster med den faktiska användaren via ICurrentUser —
+// HttpContext-claims vid OIDC, AuthService-fallback vid demo-login.
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -140,18 +146,44 @@
var app = builder.Build();
-// Seed database (auto-migrate + seed on startup)
+// Skapa schema + (valfritt) demo-seed vid uppstart.
using (var scope = app.Services.CreateScope())
{
try
{
var db = scope.ServiceProvider.GetRequiredService();
+ // OBS: EnsureCreatedAsync skapar schemat från NUVARANDE modell på en TOM databas
+ // men uppdaterar ALDRIG en befintlig — efter modelländringar måste demo-DB:n
+ // (pgdata-volymen) återskapas vid deploy. Migrations-mappen släpar efter
+ // modellen, så Database.Migrate() är inte säkert att byta till förrän en
+ // ny migration genererats (dotnet ef migrations add) och verifierats.
await db.Database.EnsureCreatedAsync();
- await SeedData.SeedAsync(db);
+
+ // Demo-seed-vakt: en produktionsdatabas får aldrig fyllas med demodata av
+ // misstag. Sätt SeedDemoData=true (env/appsettings) i demo-/utvecklingsmiljöer
+ // — docker-compose.yml gör det för demon.
+ if (app.Configuration.GetValue("SeedDemoData"))
+ {
+ await SeedData.SeedAsync(db);
+ }
}
catch (Exception ex) { var logger = scope.ServiceProvider.GetRequiredService>(); logger.LogError(ex, "Database seed failed"); }
}
+// Bakom cloudflared/reverse proxy: skriv om Connection.RemoteIpAddress från
+// X-Forwarded-For INNAN rate-limitern (och loggningen) läser den — annars hamnar
+// alla besökare i proxyns/tunnelns partition (en gemensam bucket → godtycklig 429).
+// Betrodda proxyer = loopback (default) + privata nät (dockerbroar/cloudflared);
+// klienter ute på internet kan inte spoofa XFF den vägen.
+var forwardedOptions = new ForwardedHeadersOptions
+{
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
+};
+forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(System.Net.IPAddress.Parse("10.0.0.0"), 8));
+forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(System.Net.IPAddress.Parse("172.16.0.0"), 12));
+forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(System.Net.IPAddress.Parse("192.168.0.0"), 16));
+app.UseForwardedHeaders(forwardedOptions);
+
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
diff --git a/src/Web/Services/HttpContextCurrentUser.cs b/src/Web/Services/HttpContextCurrentUser.cs
new file mode 100644
index 0000000..e28fdf7
--- /dev/null
+++ b/src/Web/Services/HttpContextCurrentUser.cs
@@ -0,0 +1,73 @@
+using System.Security.Claims;
+using RegionHR.Infrastructure.Persistence;
+
+namespace RegionHR.Web.Services;
+
+///
+/// HttpContext-baserad för granskningsloggen:
+/// läser i första hand den inloggades claims ur
+/// (satta av cookie-/OIDC-autentiseringen när Entra är påslaget, se Program.cs).
+///
+/// Vid demo-inloggning (SITHS/BankID-simulering) bär HTTP-kontexten ingen
+/// principal — identiteten lever per Blazor-circuit i
+/// (ProtectedSessionStorage). Därför faller vi tillbaka på AuthService så att
+/// AuditInterceptor stämplar den FAKTISKA användaren även i demo-läget.
+/// Saknas användare i båda källorna returneras null och interceptorn faller
+/// tillbaka på "system" (bakgrundsjobb, seeding).
+///
+/// Registreras scoped tillsammans med AuditInterceptor (se DI-wiring i
+/// Program.cs / Infrastructure.DependencyInjection).
+///
+public sealed class HttpContextCurrentUser : ICurrentUser
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly AuthService _auth;
+
+ public HttpContextCurrentUser(IHttpContextAccessor httpContextAccessor, AuthService auth)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ _auth = auth;
+ }
+
+ ///
+ public string? Id
+ {
+ get
+ {
+ var user = AuthenticatedHttpUser();
+ if (user is not null)
+ {
+ // Samma claim-namn som OpenHrAuthStateProvider/EntraClaimsMapper sätter.
+ return user.FindFirst("employee_id")?.Value
+ ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
+ ?? user.FindFirst(ClaimTypes.Name)?.Value;
+ }
+
+ // Demo-login: identiteten finns i circuit-sessionen.
+ if (_auth.IsLoggedIn)
+ return _auth.EmployeeId?.ToString() ?? _auth.UserName;
+
+ return null;
+ }
+ }
+
+ ///
+ public string? Namn
+ {
+ get
+ {
+ var user = AuthenticatedHttpUser();
+ if (user is not null)
+ return user.Identity?.Name ?? user.FindFirst(ClaimTypes.Name)?.Value;
+
+ return _auth.IsLoggedIn ? _auth.UserName : null;
+ }
+ }
+
+ /// HttpContext-principal om den finns OCH är autentiserad, annars null.
+ private ClaimsPrincipal? AuthenticatedHttpUser()
+ {
+ var user = _httpContextAccessor.HttpContext?.User;
+ return user?.Identity?.IsAuthenticated == true ? user : null;
+ }
+}
diff --git a/src/Web/Services/LoneoversynService.cs b/src/Web/Services/LoneoversynService.cs
index eab5908..c289d74 100644
--- a/src/Web/Services/LoneoversynService.cs
+++ b/src/Web/Services/LoneoversynService.cs
@@ -2,6 +2,7 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using RegionHR.Core.Domain;
+using RegionHR.Infrastructure.Payroll;
using RegionHR.Infrastructure.Persistence;
using RegionHR.SalaryReview.Domain;
using RegionHR.SalaryReview.Services;
@@ -21,9 +22,21 @@ public sealed class LoneoversynService
{
private readonly IDbContextFactory _dbFactory;
private readonly SalaryReviewExecutionEngine _engine = new();
-
- public LoneoversynService(IDbContextFactory dbFactory)
- => _dbFactory = dbFactory;
+ private readonly PayrollBatchService? _payrollBatch;
+
+ /// Databasfabrik (samma mönster som övriga Web-tjänster).
+ ///
+ /// Lönekörningstjänsten som skapar den retroaktiva lönekörningen vid genomförande.
+ /// Valfri: utan den appliceras ny lön fortfarande, men ingen retro-körning skapas
+ /// (t.ex. i importflödet som aldrig genomför rundan).
+ ///
+ public LoneoversynService(
+ IDbContextFactory dbFactory,
+ PayrollBatchService? payrollBatch = null)
+ {
+ _dbFactory = dbFactory;
+ _payrollBatch = payrollBatch;
+ }
public async Task> HamtaRundorAsync(CancellationToken ct = default)
{
@@ -125,9 +138,11 @@ public async Task GodkannFackligAsync(Guid rundaId, string fackligRepresentant,
///
/// Genomför rundan: applicerar varje godkänt förslag som ny lön på anställningen,
- /// beräknar retroaktivt belopp och sparar allt i samma transaktion.
+ /// beräknar retroaktivt belopp och sparar allt i samma transaktion. Därefter skapas
+ /// en retroaktiv lönekörning per månad i fönstret [ikraftträdande, genomförande) så
+ /// att differensen faktiskt betalas ut — inte bara visas i UI:t.
///
- public async Task GenomforAsync(
+ public async Task GenomforAsync(
Guid rundaId, string genomfordAv, DateOnly? genomforandeDatum = null, CancellationToken ct = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
@@ -155,7 +170,35 @@ public async Task GenomforAsync(
// En SaveChanges persisterar både rundans status/retro och de nya lönerna
// (alla aggregat spåras av samma DbContext).
await db.SaveChangesAsync(ct);
- return resultat;
+
+ // Skapa retroaktiva lönekörningar för varje månad mellan ikraftträdande och
+ // genomförande. Körs EFTER SaveChanges så att omräkningen ser de nya lönerna.
+ // Fel per period (t.ex. ingen ursprunglig körning för månaden) stoppar inte
+ // genomförandet — lönerna är redan applicerade — utan rapporteras till anroparen.
+ var retroKorningar = new List();
+ var retroFel = new List();
+ if (_payrollBatch is not null)
+ {
+ var period = new DateOnly(runda.IkrafttradandeDatum.Year, runda.IkrafttradandeDatum.Month, 1);
+ var genomforandeManad = new DateOnly(datum.Year, datum.Month, 1);
+ while (period < genomforandeManad)
+ {
+ var retroPeriod = $"{period.Year:D4}-{period.Month:D2}";
+ try
+ {
+ await _payrollBatch.ExecuteRetroactiveRunAsync(
+ datum.Year, datum.Month, retroPeriod, genomfordAv, ct);
+ retroKorningar.Add(retroPeriod);
+ }
+ catch (Exception ex)
+ {
+ retroFel.Add($"{retroPeriod}: {ex.Message}");
+ }
+ period = period.AddMonths(1);
+ }
+ }
+
+ return new LoneoversynGenomforandeResultat(resultat, retroKorningar, retroFel);
}
/// Namnuppslag EmployeeId → fullständigt namn för att visa förslag/ändringar.
@@ -229,6 +272,10 @@ public async Task FortolkaFilAsync(
decimal? nuvarande = anstallning?.Manadslon.Amount;
decimal? okning = (anstallning is not null && parsad.NyLon is { } ny) ? ny - anstallning.Manadslon.Amount : null;
+ // Speglar aggregatets spärr: lönesänkning avvisas redan i förhandsgranskningen.
+ if (okning is < 0)
+ fel.Add("Föreslagen lön är lägre än nuvarande lön — lönesänkning kan inte importeras.");
+
// Budgetkontroll i filordning, speglar aggregatets sekventiella kontroll.
if (fel.Count == 0 && okning is { } o)
{
@@ -406,6 +453,19 @@ await db.SalaryReviewRounds.Include(r => r.Forslag).FirstOrDefaultAsync(r => r.I
?? throw new InvalidOperationException($"Löneöversynsrunda {rundaId} hittades inte.");
}
+///
+/// Utfallet av ett genomförande: själva löneappliceringen plus vilka retroaktiva
+/// lönekörningar som skapades (en per månad i retrofönstret) respektive misslyckades.
+///
+public sealed record LoneoversynGenomforandeResultat(
+ SalaryReviewExecutionResult Genomforande,
+ IReadOnlyList RetroaktivaKorningar,
+ IReadOnlyList RetroaktivaFel)
+{
+ public int AntalAnstallda => Genomforande.AntalAnstallda;
+ public Money TotalRetroaktivt => Genomforande.TotalRetroaktivt;
+}
+
/// En anställd som kan få ett löneförslag i en runda.
public sealed record LoneKandidat(
EmployeeId AnstallId,
diff --git a/src/Web/Services/RouteAccessPolicy.cs b/src/Web/Services/RouteAccessPolicy.cs
index 6708aad..471ee34 100644
--- a/src/Web/Services/RouteAccessPolicy.cs
+++ b/src/Web/Services/RouteAccessPolicy.cs
@@ -10,6 +10,10 @@ namespace RegionHR.Web.Services;
/// (IsChef/IsHR) så att direkt-URL beter sig som menyn: en Anställd når aldrig
/// lön, audit, GDPR eller admin; en Chef når team/schema/rapporter men inte lön.
/// Roller i systemet: "Admin", "HR", "Chef", "Anställd".
+///
+/// Policyn är fail-closed: en rutt som inte matchar någon regel nekas för ALLA
+/// roller. Varje ny sida måste därför registreras i (eller
+/// specialfallen i ) för att bli nåbar.
///
public static class RouteAccessPolicy
{
@@ -22,6 +26,7 @@ public static class RouteAccessPolicy
private static readonly string[] HrAdmin = { HR, Admin };
private static readonly string[] ChefHrAdmin = { Chef, HR, Admin };
private static readonly string[] AllaInloggade = { Anstalld, Chef, HR, Admin };
+ private static readonly string[] Ingen = Array.Empty();
///
/// Öppna rutter (även oinloggade). Endast dessa nås utan session.
@@ -50,6 +55,8 @@ private static (string Prefix, string[] Roles)[] BuildRules()
("/integrationer", HrAdmin),
("/offboarding", HrAdmin),
("/helpdesk/agent", HrAdmin),
+ ("/las", HrAdmin),
+ ("/anpassat", HrAdmin),
// ── Chefsfunktioner (Chef/HR/Admin) ──
("/chef", ChefHrAdmin),
@@ -143,8 +150,11 @@ public static IReadOnlyCollection AllowedRolesFor(string path)
return roles;
}
- // Fail-safe: okänd rutt kräver minst inloggning (any authenticated).
- return AllaInloggade;
+ // Fail-closed: okänd rutt NEKAS för alla tills en regel uttryckligen
+ // tillåter den. En ny sida måste alltså registreras i Rules ovan för
+ // att bli nåbar — hellre ett synligt "saknar behörighet" än att en
+ // oskyddad rutt läcker till fel roll.
+ return Ingen;
}
private static string Normalize(string path)
diff --git a/tests/Agreements.Tests/ABOTillaggSatserTests.cs b/tests/Agreements.Tests/ABOTillaggSatserTests.cs
index 556284d..2868162 100644
--- a/tests/Agreements.Tests/ABOTillaggSatserTests.cs
+++ b/tests/Agreements.Tests/ABOTillaggSatserTests.cs
@@ -8,7 +8,7 @@ namespace RegionHR.Agreements.Tests;
/// Verifierar de årsversionerade O-tilläggssatserna mot AB § 21 mom. 1.
/// KÄLLA: SKR "Allmänna Bestämmelser (AB) 25 i lydelse 2025-04-01", § 21.
/// Satser fr.o.m. 2025-04-01: A=126,90 (natt 152,30), B=66,10 (natt 76,00), C=56,70, D=25,60.
-/// Satser fr.o.m. 2026-04-01: A=130,70 (natt 156,90), B=68,10 (natt 78,30), C=58,40, D=26,40.
+/// Satser fr.o.m. 2026-04-01: A=130,70 (natt 156,90), B=68,10 (natt 101,80 per anm. 1: +30 %), C=58,40, D=26,40.
///
public class ABOTillaggSatserTests
{
@@ -40,7 +40,8 @@ public void Grundsats_2026_MatcharAB21()
Assert.Equal(152.30m, ABOTillaggSatser.Nattsats(OBCategory.Storhelg, Ar2025));
Assert.Equal(76.00m, ABOTillaggSatser.Nattsats(OBCategory.Helg, Ar2025));
Assert.Equal(156.90m, ABOTillaggSatser.Nattsats(OBCategory.Storhelg, Ar2026));
- Assert.Equal(78.30m, ABOTillaggSatser.Nattsats(OBCategory.Helg, Ar2026));
+ // AB 25 § 21 anm. 1: B-satsen höjd med 30 % nattetid → 78,30 × 1,30 = 101,80.
+ Assert.Equal(101.80m, ABOTillaggSatser.Nattsats(OBCategory.Helg, Ar2026));
}
[Fact]
diff --git a/tests/Analytics.Tests/BeslutsstodKpiServiceTests.cs b/tests/Analytics.Tests/BeslutsstodKpiServiceTests.cs
index b8751b6..9b925c2 100644
--- a/tests/Analytics.Tests/BeslutsstodKpiServiceTests.cs
+++ b/tests/Analytics.Tests/BeslutsstodKpiServiceTests.cs
@@ -51,6 +51,15 @@ public void Berakna_Turnover_LeaversOverActiveHeadcount()
Assert.Equal(50.0m, kpi.PersonalomsattningProcent); // 1 avslut / 2 aktiva
}
+ /// Skapar en godkänd sjukfrånvaro (Utkast → Inskickad → Godkänd).
+ private static LeaveRequest GodkandSjukfranvaro(Employee emp, DateOnly from, DateOnly to)
+ {
+ var leave = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro, from, to, null);
+ leave.SkickaIn();
+ leave.Godkann(Guid.NewGuid(), null);
+ return leave;
+ }
+
[Fact]
public void Berakna_SickLeavePercent_OverPossibleWorkdays()
{
@@ -58,9 +67,8 @@ public void Berakna_SickLeavePercent_OverPossibleWorkdays()
var emp = Anstalld();
Tillsvidare(emp, enhet);
- // 5 arbetsdagar sjukfrånvaro (mån–fre).
- var leave = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
- new DateOnly(2026, 1, 5), new DateOnly(2026, 1, 9), null);
+ // 5 arbetsdagar godkänd sjukfrånvaro (mån–fre).
+ var leave = GodkandSjukfranvaro(emp, new DateOnly(2026, 1, 5), new DateOnly(2026, 1, 9));
Assert.Equal(5, leave.AntalDagar);
var res = BeslutsstodKpiService.Berakna([emp], [], [leave], [], [enhet], Snapshot);
@@ -77,14 +85,40 @@ public void Berakna_SickLeaveOutsideWindow_IsIgnored()
var emp = Anstalld();
Tillsvidare(emp, enhet);
- // Sjukfrånvaro för > 12 mån sedan (utanför fönstret).
- var leave = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
- new DateOnly(2024, 1, 5), new DateOnly(2024, 1, 12), null);
+ // Godkänd sjukfrånvaro för > 12 mån sedan (utanför fönstret).
+ var leave = GodkandSjukfranvaro(emp, new DateOnly(2024, 1, 5), new DateOnly(2024, 1, 12));
var res = BeslutsstodKpiService.Berakna([emp], [], [leave], [], [enhet], Snapshot);
Assert.Equal(0m, ForEnhet(res, enhet).SjukfranvaroProcent);
}
+ [Fact]
+ public void Berakna_NonApprovedSickLeave_IsExcluded()
+ {
+ var enhet = Enhet();
+ var emp = Anstalld();
+ Tillsvidare(emp, enhet);
+
+ // Utkast — räknas inte.
+ var utkast = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 1, 5), new DateOnly(2026, 1, 9), null);
+
+ // Avslagen — räknas inte.
+ var avslagen = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 2, 2), new DateOnly(2026, 2, 6), null);
+ avslagen.SkickaIn();
+ avslagen.Avvisa(Guid.NewGuid(), "Avslag");
+
+ // Återkallad — räknas inte.
+ var aterkallad = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 3, 2), new DateOnly(2026, 3, 6), null);
+ aterkallad.Aterkalla();
+
+ var res = BeslutsstodKpiService.Berakna(
+ [emp], [], [utkast, avslagen, aterkallad], [], [enhet], Snapshot);
+ Assert.Equal(0m, ForEnhet(res, enhet).SjukfranvaroProcent);
+ }
+
[Fact]
public void Berakna_Bemanningsgrad_FilledOverStaffablePositions()
{
diff --git a/tests/Analytics.Tests/BiDwExportBuilderTests.cs b/tests/Analytics.Tests/BiDwExportBuilderTests.cs
index 3b2af10..ae94188 100644
--- a/tests/Analytics.Tests/BiDwExportBuilderTests.cs
+++ b/tests/Analytics.Tests/BiDwExportBuilderTests.cs
@@ -119,6 +119,8 @@ public void Bygg_SickLeave_ProducesAbsenceFactAttributedToUnit()
AnstallningTillsvidare(emp, enhet, 100m, "Sjuksköterska");
var leave = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
new DateOnly(2026, 4, 6), new DateOnly(2026, 4, 8), "Influensa");
+ leave.SkickaIn();
+ leave.Godkann(Guid.NewGuid(), null);
var schema = BiDwExportBuilder.Bygg([emp], [], [leave], [enhet], Snapshot);
@@ -130,6 +132,28 @@ public void Bygg_SickLeave_ProducesAbsenceFactAttributedToUnit()
Assert.Equal(leave.AntalDagar, fakta.AntalDagar);
}
+ [Fact]
+ public void Bygg_NonApprovedLeave_ProducesNoAbsenceFact()
+ {
+ var enhet = Enhet("Akutmottagning", "KST100");
+ var emp = Kvinna();
+ AnstallningTillsvidare(emp, enhet, 100m, "Sjuksköterska");
+
+ // Utkast — inte faktagrundande.
+ var utkast = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 4, 6), new DateOnly(2026, 4, 8), null);
+
+ // Avslagen — inte faktagrundande.
+ var avslagen = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Semester,
+ new DateOnly(2026, 5, 4), new DateOnly(2026, 5, 8), null);
+ avslagen.SkickaIn();
+ avslagen.Avvisa(Guid.NewGuid(), "Avslag");
+
+ var schema = BiDwExportBuilder.Bygg([emp], [], [utkast, avslagen], [enhet], Snapshot);
+
+ Assert.Empty(schema.FaktaFranvaro);
+ }
+
[Fact]
public void Bygg_MalePersonnummer_MapsToKonM()
{
diff --git a/tests/Analytics.Tests/KPICalculationServiceTests.cs b/tests/Analytics.Tests/KPICalculationServiceTests.cs
index 6ee1490..1876e1e 100644
--- a/tests/Analytics.Tests/KPICalculationServiceTests.cs
+++ b/tests/Analytics.Tests/KPICalculationServiceTests.cs
@@ -334,6 +334,77 @@ public async Task CalculateAllAsync_MultipleKPIs_CreatesAllSnapshots()
Assert.All(result, s => Assert.Equal("2026-Q1", s.Period));
}
+ [Fact]
+ public async Task CalculateAllAsync_Headcount_CountsDistinctIndividualsAndExcludesFutureEmployments()
+ {
+ using var db = CreateInMemoryDb(nameof(CalculateAllAsync_Headcount_CountsDistinctIndividualsAndExcludesFutureEmployments));
+
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var enhetId = OrganizationId.New();
+
+ // En person med två parallella aktiva anställningar → räknas som EN individ.
+ var dubbel = Employee.Skapa(Personnummer.CreateValidated("19900101234"), "Anna", "Andersson");
+ dubbel.LaggTillAnstallning(enhetId, EmploymentType.Tillsvidare, CollectiveAgreementType.AB,
+ Money.SEK(30000), new Percentage(50), today.AddYears(-2));
+ dubbel.LaggTillAnstallning(enhetId, EmploymentType.Tillsvidare, CollectiveAgreementType.AB,
+ Money.SEK(30000), new Percentage(50), today.AddYears(-1));
+
+ // Framtida (ej påbörjad) anställning → räknas inte.
+ var framtida = Employee.Skapa(Personnummer.CreateValidated("19850615237"), "Erik", "Eriksson");
+ framtida.LaggTillAnstallning(enhetId, EmploymentType.Tillsvidare, CollectiveAgreementType.AB,
+ Money.SEK(30000), new Percentage(100), today.AddDays(30));
+
+ db.Employees.AddRange(dubbel, framtida);
+ db.KPIDefinitions.Add(KPIDefinition.Skapa("Headcount", "Workforce", "COUNT(*)", "count", "HigherIsBetter", 100, 80, 50));
+ await db.SaveChangesAsync();
+
+ var service = new KPICalculationService(db);
+ var result = await service.CalculateAllAsync("2026-Q1");
+
+ Assert.Single(result);
+ Assert.Equal(1m, result[0].Varde);
+ }
+
+ [Fact]
+ public async Task CalculateAllAsync_SickLeave_ExcludesNonApprovedRequests()
+ {
+ using var db = CreateInMemoryDb(nameof(CalculateAllAsync_SickLeave_ExcludesNonApprovedRequests));
+
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var enhetId = OrganizationId.New();
+ var emp = Employee.Skapa(Personnummer.CreateValidated("19900101234"), "Anna", "Andersson");
+ emp.LaggTillAnstallning(enhetId, EmploymentType.Tillsvidare, CollectiveAgreementType.AB,
+ Money.SEK(30000), new Percentage(100), today.AddYears(-2));
+ db.Employees.Add(emp);
+
+ // Godkänd sjukfrånvaro (räknas): mån–fre = 5 dagar.
+ var godkand = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 1, 5), new DateOnly(2026, 1, 9), null);
+ godkand.SkickaIn();
+ godkand.Godkann(Guid.NewGuid(), null);
+
+ // Utkast (räknas inte).
+ var utkast = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 1, 12), new DateOnly(2026, 1, 16), null);
+
+ // Avslagen (räknas inte).
+ var avslagen = LeaveRequest.Skapa(emp.Id.Value, LeaveType.Sjukfranvaro,
+ new DateOnly(2026, 2, 2), new DateOnly(2026, 2, 6), null);
+ avslagen.SkickaIn();
+ avslagen.Avvisa(Guid.NewGuid(), "Avslag");
+
+ db.LeaveRequests.AddRange(godkand, utkast, avslagen);
+ db.KPIDefinitions.Add(KPIDefinition.Skapa("Sjukfranvaro %", "Health", "sick/possible*100", "percent", "LowerIsBetter", 3, 5, 8));
+ await db.SaveChangesAsync();
+
+ var service = new KPICalculationService(db);
+ var result = await service.CalculateAllAsync("2026-Q1");
+
+ Assert.Single(result);
+ // 5 godkända dagar / (1 individ * 3 mån * 21 arbetsdagar) * 100
+ Assert.Equal(5m / 63m * 100m, result[0].Varde);
+ }
+
[Fact]
public async Task CalculateAllAsync_SnapshotsArePersistedToDb()
{
diff --git a/tests/Audit.Tests/AuditInterceptorTests.cs b/tests/Audit.Tests/AuditInterceptorTests.cs
index 08bc81a..4a12eee 100644
--- a/tests/Audit.Tests/AuditInterceptorTests.cs
+++ b/tests/Audit.Tests/AuditInterceptorTests.cs
@@ -14,18 +14,30 @@ public class AuditInterceptorTests : IDisposable
public AuditInterceptorTests()
{
- var interceptor = new AuditInterceptor();
+ // Ingen ICurrentUser → interceptorn ska falla tillbaka på "system".
+ _dbContext = CreateContext(currentUser: null);
+ }
+
+ public void Dispose()
+ {
+ _dbContext.Dispose();
+ }
+
+ private static RegionHRDbContext CreateContext(ICurrentUser? currentUser)
+ {
+ var interceptor = new AuditInterceptor(currentUser);
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(databaseName: $"AuditTest-{Guid.NewGuid()}")
.AddInterceptors(interceptor)
.Options;
- _dbContext = new RegionHRDbContext(options);
+ return new RegionHRDbContext(options);
}
- public void Dispose()
+ private sealed class FakeCurrentUser(string? id, string? namn) : ICurrentUser
{
- _dbContext.Dispose();
+ public string? Id => id;
+ public string? Namn => namn;
}
[Fact]
@@ -53,6 +65,7 @@ public async Task SaveChanges_WhenEntityAdded_CreatesAuditEntryWithCreateAction(
Assert.Equal(AuditAction.Create, audit.Action);
Assert.Null(audit.OldValues);
Assert.NotNull(audit.NewValues);
+ // Utan ICurrentUser (bakgrundsjobb/seeding) stämplas posten "system".
Assert.Equal("system", audit.UserId);
Assert.Equal("system", audit.UserName);
@@ -157,4 +170,47 @@ public async Task SaveChanges_AuditEntryItself_IsNotAudited()
Assert.Single(allAudits);
Assert.Equal("TestEntity", allAudits[0].EntityType);
}
+
+ [Fact]
+ public async Task SaveChanges_WithCurrentUser_StampsActualUser()
+ {
+ // Arrange - interceptor med inloggad användare
+ var employeeId = Guid.NewGuid().ToString();
+ await using var db = CreateContext(new FakeCurrentUser(employeeId, "Eva Nilsson"));
+
+ var employee = Employee.Skapa(
+ new Personnummer("198112289874"),
+ "Anna",
+ "Svensson");
+
+ // Act
+ db.Employees.Add(employee);
+ await db.SaveChangesAsync();
+
+ // Assert - posten är spårbar till den faktiska användaren, inte "system"
+ var audit = Assert.Single(db.AuditEntries.Where(a => a.EntityType == "Employee").ToList());
+ Assert.Equal(employeeId, audit.UserId);
+ Assert.Equal("Eva Nilsson", audit.UserName);
+ }
+
+ [Fact]
+ public async Task SaveChanges_WithEmptyCurrentUser_FallsBackToSystem()
+ {
+ // Arrange - ICurrentUser finns men saknar identitet (utloggad/anonym)
+ await using var db = CreateContext(new FakeCurrentUser(null, " "));
+
+ var employee = Employee.Skapa(
+ new Personnummer("198112289874"),
+ "Anna",
+ "Svensson");
+
+ // Act
+ db.Employees.Add(employee);
+ await db.SaveChangesAsync();
+
+ // Assert - tom/whitespace-identitet får aldrig ge tomma stämplar
+ var audit = Assert.Single(db.AuditEntries.Where(a => a.EntityType == "Employee").ToList());
+ Assert.Equal("system", audit.UserId);
+ Assert.Equal("system", audit.UserName);
+ }
}
diff --git a/tests/IntegrationHub.Tests/SCBLonestatistikGeneratorTests.cs b/tests/IntegrationHub.Tests/SCBLonestatistikGeneratorTests.cs
index 2aeb76e..69190d2 100644
--- a/tests/IntegrationHub.Tests/SCBLonestatistikGeneratorTests.cs
+++ b/tests/IntegrationHub.Tests/SCBLonestatistikGeneratorTests.cs
@@ -128,6 +128,23 @@ public void Kon_normaliseras_fran_personnummer()
Assert.Contains(result.Grupper, g => g.Kon == "M");
}
+ [Fact]
+ public void Okant_kon_bildar_egen_grupp_och_snedvrider_inte_loneandelen()
+ {
+ var input = Bygg(
+ Ind("204012", "Sjuksköterska", "Man", 40000m, 100m),
+ Ind("204012", "Sjuksköterska", "Kvinna", 38000m, 100m),
+ Ind("204012", "Sjuksköterska", "", 90000m, 100m));
+
+ var result = new SCBLonestatistikGenerator().Generera(input);
+
+ Assert.Equal(3, result.Grupper.Count);
+ Assert.Contains(result.Grupper, g => g.Kon == "Okänt");
+ Assert.Equal(1, result.Grupper.Single(g => g.Kon == "M").Antal);
+ // Individen med okänt kön räknas inte in bland män: 38000 / 40000 = 95.0 %
+ Assert.Equal(95.0m, result.KvinnorLoneandelProcent);
+ }
+
[Fact]
public void Tom_indata_ger_inga_grupper_men_giltig_fil()
{
diff --git a/tests/IntegrationHub.Tests/SKRNovemberstatistikGeneratorTests.cs b/tests/IntegrationHub.Tests/SKRNovemberstatistikGeneratorTests.cs
index 6f8161f..3422124 100644
--- a/tests/IntegrationHub.Tests/SKRNovemberstatistikGeneratorTests.cs
+++ b/tests/IntegrationHub.Tests/SKRNovemberstatistikGeneratorTests.cs
@@ -92,6 +92,20 @@ public void Filen_har_status_och_totalrad()
Assert.EndsWith(".csv", result.Filnamn);
}
+ [Fact]
+ public void Okant_kon_bildar_egen_grupp_i_stallet_for_att_raknas_som_man()
+ {
+ var input = Bygg(2025,
+ Ind("Sjuksköterska", "Man", true, 100m, 40),
+ Ind("Sjuksköterska", "", true, 100m, 35));
+
+ var result = new SKRNovemberstatistikGenerator().Generera(input);
+
+ Assert.Equal(2, result.Grupper.Count);
+ Assert.Contains(result.Grupper, g => g.Kon == "Okänt");
+ Assert.Equal(1, result.Grupper.Single(g => g.Kon == "M").AntalAnstallda);
+ }
+
[Fact]
public void Saknad_personalgrupp_blir_ospecificerad()
{
diff --git a/tests/LAS.Tests/LASAccumulationTests.cs b/tests/LAS.Tests/LASAccumulationTests.cs
index 563e487..e83ce61 100644
--- a/tests/LAS.Tests/LASAccumulationTests.cs
+++ b/tests/LAS.Tests/LASAccumulationTests.cs
@@ -110,4 +110,119 @@ public void Vikariat_Over2Ar_TriggrarKonvertering()
Assert.Equal(LASStatus.KonverteradTillTillsvidare, acc.Status);
}
+
+ // ── Off-by-one: lagen säger "mer än" — konvertering först när gränsen PASSERAS ──
+
+ [Fact]
+ public void SAVA_Exakt365Dagar_KonverterasInte()
+ {
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var slut = DateOnly.FromDateTime(DateTime.Today);
+ var start = slut.AddDays(-364); // exakt 365 dagar
+
+ acc.LaggTillPeriod(start, slut);
+
+ Assert.Equal(365, acc.AckumuleradeSavaDagar);
+ Assert.Equal(LASStatus.KritiskNara, acc.Status); // nära gränsen, men INTE konverterad
+ Assert.Null(acc.KonverteringsDatum);
+ }
+
+ [Fact]
+ public void SAVA_366Dagar_GransenPasserad_Konverteras()
+ {
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var slut = DateOnly.FromDateTime(DateTime.Today);
+ var start = slut.AddDays(-365); // 366 dagar — mer än 365
+
+ acc.LaggTillPeriod(start, slut);
+
+ Assert.Equal(LASStatus.KonverteradTillTillsvidare, acc.Status);
+ }
+
+ [Fact]
+ public void Vikariat_Exakt730Dagar_KonverterasInte()
+ {
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.Vikariat);
+ var slut = DateOnly.FromDateTime(DateTime.Today);
+ var start = slut.AddDays(-729); // exakt 730 dagar
+
+ acc.LaggTillPeriod(start, slut);
+
+ Assert.Equal(730, acc.AckumuleradeVikariatDagar);
+ Assert.NotEqual(LASStatus.KonverteradTillTillsvidare, acc.Status);
+ }
+
+ [Fact]
+ public void Foretradesratt_BeviljasEj_VidExakt274Dagar()
+ {
+ // Lagen säger "mer än" 9 månader — exakt 274 dagar räcker INTE.
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var slut = DateOnly.FromDateTime(DateTime.Today.AddDays(-10));
+ var start = slut.AddDays(-273); // exakt 274 dagar
+
+ acc.LaggTillPeriod(start, slut);
+ acc.SattForetradesratt(slut);
+
+ Assert.False(acc.HarForetradesratt);
+ }
+
+ // ── SAVA- och vikariatstid ackumuleras separat mot sina respektive gränser ──
+
+ [Fact]
+ public void BlandadeFormer_AckumulerasSeparat_MotRespektiveGrans()
+ {
+ // 200 SAVA-dagar + 400 vikariatsdagar får inte summeras till 600 mot någon
+ // enskild gräns: SAVA 200 ≤ 365 och vikariat 400 ≤ 730 → ingen konvertering.
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+
+ acc.LaggTillPeriod(idag.AddDays(-799), idag.AddDays(-600), form: EmploymentType.SAVA); // 200 dagar
+ acc.LaggTillPeriod(idag.AddDays(-500), idag.AddDays(-101), form: EmploymentType.Vikariat); // 400 dagar
+
+ Assert.Equal(200, acc.AckumuleradeSavaDagar);
+ Assert.Equal(400, acc.AckumuleradeVikariatDagar);
+ Assert.Equal(600, acc.AckumuleradeDagar);
+ Assert.NotEqual(LASStatus.KonverteradTillTillsvidare, acc.Status);
+ }
+
+ [Fact]
+ public void Foretradesratt_Vikariatstid_BedomsMotVikariatskravet()
+ {
+ // Vikariatstid bedöms mot vikariatskravet (mer än 365 dagar), inte SAVA-kravet
+ // (274) — 300 vikariatsdagar ger alltså INTE företrädesrätt.
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var slut = DateOnly.FromDateTime(DateTime.Today.AddDays(-10));
+
+ acc.LaggTillPeriod(slut.AddDays(-299), slut, form: EmploymentType.Vikariat); // 300 dagar
+ acc.SattForetradesratt(slut);
+
+ Assert.False(acc.HarForetradesratt);
+ }
+
+ [Fact]
+ public void LaggTillPeriod_MedOgiltigForm_KastarException()
+ {
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+
+ Assert.Throws(() =>
+ acc.LaggTillPeriod(idag.AddDays(-10), idag, form: EmploymentType.Tillsvidare));
+ }
+
+ [Fact]
+ public void Omberakna_AvkonverterarInte_NarFonstretGliderVidare()
+ {
+ // Den återkommande omberäkningen (dagligt jobb) flyttar 5-årsfönstret framåt.
+ // En redan konverterad ackumulering får INTE "avkonverteras" när perioderna
+ // så småningom glider ut ur fönstret — konvertering är enkelriktad.
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ var idag = DateOnly.FromDateTime(DateTime.Today);
+ acc.LaggTillPeriod(idag.AddDays(-370), idag); // 371 dagar > 365 → konverterad
+ Assert.Equal(LASStatus.KonverteradTillTillsvidare, acc.Status);
+
+ acc.Omberakna(idag.AddYears(6)); // fönstret har glidit förbi alla perioder
+
+ Assert.Equal(LASStatus.KonverteradTillTillsvidare, acc.Status);
+ Assert.NotNull(acc.KonverteringsDatum);
+ }
}
diff --git a/tests/LAS.Tests/LASKorrigeringKonverteringTests.cs b/tests/LAS.Tests/LASKorrigeringKonverteringTests.cs
index 1ccc9aa..ec5a62a 100644
--- a/tests/LAS.Tests/LASKorrigeringKonverteringTests.cs
+++ b/tests/LAS.Tests/LASKorrigeringKonverteringTests.cs
@@ -109,6 +109,21 @@ public void KonverteraTillTillsvidare_KraverBeslutsfattare()
Assert.Throws(() => acc.KonverteraTillTillsvidare(Dag(0), " "));
}
+ [Fact]
+ public void ManuellKonvertering_OverleverOmberakning()
+ {
+ // En manuell konvertering (HR-beslut, under gränsen) får inte wipas av den
+ // återkommande omberäkningen i det dagliga jobbet.
+ var acc = LASAccumulation.Skapa(EmployeeId.New(), EmploymentType.SAVA);
+ acc.LaggTillPeriod(Dag(-120), Dag(0)); // under gräns
+ Assert.True(acc.KonverteraTillTillsvidare(Dag(0), "Karl Berg"));
+
+ acc.Omberakna(DateOnly.FromDateTime(DateTime.Today));
+
+ Assert.Equal(LASStatus.KonverteradTillTillsvidare, acc.Status);
+ Assert.Equal(Dag(0), acc.KonverteringsDatum);
+ }
+
// ── Service: skrivväg + attestkontroll ──────────────────────────
private static LASService NyService(out InMemoryLASRepository repo)
@@ -203,7 +218,7 @@ public async Task BeviljaForetradesrattAsync_GerForetradesratt_NarBerattigad()
var service = NyService(out var repo);
var anstalld = EmployeeId.New();
var hrAktor = Guid.NewGuid();
- // SAVA ≥ 274 dagar i 3-årsperioden → berättigad
+ // SAVA > 274 dagar i 3-årsperioden ("mer än" 9 månader) → berättigad
await service.RegistreraPeriodAsync(anstalld, EmploymentType.SAVA, Dag(-290), Dag(-10), hrAktor, "HR");
var har = await service.BeviljaForetradesrattAsync(anstalld, Dag(-10), hrAktor, "HR");
diff --git a/tests/LAS.Tests/LASServiceTests.cs b/tests/LAS.Tests/LASServiceTests.cs
index 3afc993..17c3eb4 100644
--- a/tests/LAS.Tests/LASServiceTests.cs
+++ b/tests/LAS.Tests/LASServiceTests.cs
@@ -259,6 +259,32 @@ public async Task Turordningslista_SorterasKorrekt_SistInForstUt()
Assert.Equal(id3, turordning[2].AnstallId); // Cecilia - flest dagar, sägs upp sist
}
+ [Fact]
+ public async Task RegistreraPeriod_MedAnnanForm_AckumulerarSeparat()
+ {
+ // En anställd med både SAVA- och vikariatsperioder: dagarna ska räknas
+ // separat mot respektive gräns (365 resp. 730) — inte blandas ihop.
+ var anstallId = EmployeeId.New();
+
+ await _service.RegistreraPeriodAsync(
+ anstallId, EmploymentType.SAVA,
+ DateOnly.FromDateTime(DateTime.Today.AddDays(-400)),
+ DateOnly.FromDateTime(DateTime.Today.AddDays(-201))); // 200 SAVA-dagar
+
+ await _service.RegistreraPeriodAsync(
+ anstallId, EmploymentType.Vikariat,
+ DateOnly.FromDateTime(DateTime.Today.AddDays(-200)),
+ DateOnly.FromDateTime(DateTime.Today.AddDays(-1))); // 200 vikariatsdagar
+
+ var acc = await _repository.GetByEmployeeAsync(anstallId, CancellationToken.None);
+ Assert.NotNull(acc);
+ Assert.Equal(200, acc.AckumuleradeSavaDagar);
+ Assert.Equal(200, acc.AckumuleradeVikariatDagar);
+ Assert.Equal(400, acc.AckumuleradeDagar);
+ // 400 blandade dagar passerar INTE SAVA-gränsen 365 — ingen felaktig konvertering.
+ Assert.NotEqual(LASStatus.KonverteradTillTillsvidare, acc.Status);
+ }
+
[Fact]
public async Task HamtaForetradesrattsinnehavare_ReturnerarAktiva()
{
diff --git a/tests/Leave.Tests/LeaveRequestTests.cs b/tests/Leave.Tests/LeaveRequestTests.cs
index c4a38b7..1d9f3af 100644
--- a/tests/Leave.Tests/LeaveRequestTests.cs
+++ b/tests/Leave.Tests/LeaveRequestTests.cs
@@ -228,4 +228,73 @@ public void UppdateraDag_KastarVidOgiltigtDagnummer()
Assert.Throws(() => sjuk.UppdateraDag(0));
Assert.Throws(() => sjuk.UppdateraDag(-1));
}
+
+ [Fact]
+ public void BeraknaSjukDag_Dag1PaStartdatum()
+ {
+ var sjuk = SickLeaveNotification.Skapa(_anstallId, new DateOnly(2026, 3, 16));
+
+ Assert.Equal(1, sjuk.BeraknaSjukDag(new DateOnly(2026, 3, 16)));
+ }
+
+ [Fact]
+ public void BeraknaSjukDag_HarledsUrStartdatum()
+ {
+ var sjuk = SickLeaveNotification.Skapa(_anstallId, new DateOnly(2026, 3, 16));
+
+ Assert.Equal(8, sjuk.BeraknaSjukDag(new DateOnly(2026, 3, 23)));
+ Assert.Equal(15, sjuk.BeraknaSjukDag(new DateOnly(2026, 3, 30)));
+ }
+
+ [Fact]
+ public void SynkroniseraDag_SatterFlaggorUtifranDagensDatum()
+ {
+ var sjuk = SickLeaveNotification.Skapa(_anstallId, new DateOnly(2026, 3, 16));
+
+ // Dag 8: lkarintyg krvs, FK nnu inte.
+ sjuk.SynkroniseraDag(new DateOnly(2026, 3, 23));
+ Assert.Equal(8, sjuk.SjukDag);
+ Assert.True(sjuk.LakarintygKravs);
+ Assert.False(sjuk.FKAnmalanKravs);
+
+ // Dag 15: bda flaggorna.
+ sjuk.SynkroniseraDag(new DateOnly(2026, 3, 30));
+ Assert.Equal(15, sjuk.SjukDag);
+ Assert.True(sjuk.FKAnmalanKravs);
+ }
+
+ [Fact]
+ public void Avsluta_SatterSlutDatumOchFryserDagraknignen()
+ {
+ var sjuk = SickLeaveNotification.Skapa(_anstallId, new DateOnly(2026, 3, 16));
+
+ sjuk.Avsluta(new DateOnly(2026, 3, 20)); // dag 5
+
+ Assert.True(sjuk.ArAvslutad);
+ Assert.Equal(new DateOnly(2026, 3, 20), sjuk.SlutDatum);
+ Assert.Equal(5, sjuk.SjukDag);
+ Assert.False(sjuk.LakarintygKravs);
+
+ // Dagrkningen rknar inte vidare efter avslut.
+ Assert.Equal(5, sjuk.BeraknaSjukDag(new DateOnly(2026, 4, 30)));
+ sjuk.SynkroniseraDag(new DateOnly(2026, 4, 30));
+ Assert.Equal(5, sjuk.SjukDag);
+ }
+
+ [Fact]
+ public void Avsluta_KastarOmRedanAvslutad()
+ {
+ var sjuk = SickLeaveNotification.Skapa(_anstallId, new DateOnly(2026, 3, 16));
+ sjuk.Avsluta(new DateOnly(2026, 3, 20));
+
+ Assert.Throws(() => sjuk.Avsluta(new DateOnly(2026, 3, 21)));
+ }
+
+ [Fact]
+ public void Avsluta_KastarOmSlutForeStart()
+ {
+ var sjuk = SickLeaveNotification.Skapa(_anstallId, new DateOnly(2026, 3, 16));
+
+ Assert.Throws(() => sjuk.Avsluta(new DateOnly(2026, 3, 15)));
+ }
}
diff --git a/tests/Payroll.Tests/PayrollCalculationEngineTests.cs b/tests/Payroll.Tests/PayrollCalculationEngineTests.cs
index 05cc1c1..81ae80b 100644
--- a/tests/Payroll.Tests/PayrollCalculationEngineTests.cs
+++ b/tests/Payroll.Tests/PayrollCalculationEngineTests.cs
@@ -164,6 +164,90 @@ public async Task CalculateAsync_OBPerKategori_CorrectRate(OBCategory kategori,
Assert.Equal(10 * expectedRate, result.OBTillagg.Amount);
}
+ [Fact]
+ public async Task CalculateAsync_OBHelgMedNattTimmar_AnvanderNatthojdSats()
+ {
+ // AB § 21 anm.: O-tillägg B (helg) höjs kl. 22–06. Natt-timmarna ska prissättas
+ // med den kanoniska natthöjda satsen (2025: 76,00 kr/h), inte grundsatsen.
+ SetupStandardEmployee();
+ var input = new PayrollInput
+ {
+ ArbetadeDagar = 21,
+ ArbetsdagarIManadens = 21,
+ OBTimmar = [new OBInput { Kategori = OBCategory.Helg, Timmar = 10, NattTimmar = 4 }]
+ };
+
+ var result = await _engine.CalculateAsync(
+ _runId, _employeeId, _employmentId, 2025, 6, input);
+
+ // Dagdel: 6 h × grundsats (mock: 89,00). Nattdel: 4 h × kanonisk natthöjd B-sats.
+ var nattsats = ABOTillaggSatser.Nattsats(OBCategory.Helg, new DateOnly(2025, 6, 1));
+ var expected = 6 * 89.00m + 4 * nattsats;
+ Assert.Equal(expected, result.OBTillagg.Amount);
+
+ var nattRad = result.Rader.First(r => r.Benamning == "OB-tillägg Helg natt");
+ Assert.Equal(4m, nattRad.Antal);
+ Assert.Equal(nattsats, nattRad.Sats.Amount);
+ }
+
+ [Fact]
+ public async Task CalculateAsync_OBVardagNatt_NattTimmarAndrarInteSatsen()
+ {
+ // Vardagsnatt (C) saknar natthöjning — hela beloppet prissätts med grundsatsen
+ // från regelverket även om timmarna ligger kl. 22–06.
+ SetupStandardEmployee();
+ var input = new PayrollInput
+ {
+ ArbetadeDagar = 21,
+ ArbetsdagarIManadens = 21,
+ OBTimmar = [new OBInput { Kategori = OBCategory.VardagNatt, Timmar = 8, NattTimmar = 8 }]
+ };
+
+ var result = await _engine.CalculateAsync(
+ _runId, _employeeId, _employmentId, 2025, 1, input);
+
+ Assert.Equal(8 * 152.00m, result.OBTillagg.Amount); // mock-grundsats
+ }
+
+ #endregion
+
+ #region Resersättning (resekrav)
+
+ [Fact]
+ public async Task CalculateAsync_MedResersattning_SkattefriIngarIBruttoMenEjSkatteunderlag()
+ {
+ SetupStandardEmployee(manadslon: 35000m);
+ var input = new PayrollInput
+ {
+ ArbetadeDagar = 21,
+ ArbetsdagarIManadens = 21,
+ ReseTraktamente = Money.SEK(600m),
+ ReseMilersattning = Money.SEK(250m),
+ ReseUtlagg = Money.SEK(150m)
+ };
+
+ var result = await _engine.CalculateAsync(
+ _runId, _employeeId, _employmentId, 2025, 1, input);
+
+ // Utbetalningen (brutto) inkluderar ersättningarna, skatteunderlaget gör det inte.
+ Assert.Equal(36000m, result.Brutto.Amount);
+ Assert.Equal(35000m, result.SkattepliktBrutto.Amount);
+
+ var traktamenteRad = result.Rader.First(r => r.LoneartKod == "5100");
+ Assert.Equal(600m, traktamenteRad.Belopp.Amount);
+ Assert.Equal(TaxCategory.Traktamente, traktamenteRad.Skattekategori);
+ Assert.False(traktamenteRad.ArPensionsgrundande);
+ Assert.False(traktamenteRad.ArSemestergrundande);
+
+ Assert.Equal(250m, result.Rader.First(r => r.LoneartKod == "5200").Belopp.Amount);
+ Assert.Equal(150m, result.Rader.First(r => r.LoneartKod == "5300").Belopp.Amount);
+
+ // Arbetsgivaravgifter beräknas på det avgiftspliktiga underlaget — inte på
+ // skattefria kostnadsersättningar.
+ var expectedAG = Math.Round(35000m * 0.3142m, 0, MidpointRounding.ToEven);
+ Assert.Equal(expectedAG, result.Arbetsgivaravgifter.Amount);
+ }
+
#endregion
#region Övertid
@@ -183,10 +267,10 @@ public async Task CalculateAsync_EnkelOvertid_CorrectBerakna()
var result = await _engine.CalculateAsync(
_runId, _employeeId, _employmentId, 2025, 1, input);
- // Timlön = 35000 / (38.25 * 52 / 12) = 35000 / 165.75
- // AB 25: Enkel övertid tillägg = 0.8x timlön (180% total minus 100% bas)
- var timlon = 35000m / (38.25m * 52m / 12m);
- var expected = 10 * timlon * 0.8m;
+ // AB § 20 mom. 3: timlön = månadslön / 165, enkel övertid = 180 % av timlönen
+ // per övertidstimme (hela beloppet — övertidstimmen ligger utanför månadslönen)
+ var timlon = 35000m / 165m;
+ var expected = 10 * (timlon * 1.8m);
Assert.Equal(expected, result.Overtidstillagg.Amount);
var overtidRad = result.Rader.First(r => r.LoneartKod == "1410");
@@ -208,9 +292,9 @@ public async Task CalculateAsync_KvalificeradOvertid_CorrectBerakna()
var result = await _engine.CalculateAsync(
_runId, _employeeId, _employmentId, 2025, 1, input);
- // AB 25: Kvalificerad övertid tillägg = 1.4x timlön (240% total minus 100% bas)
- var timlon = 35000m / (38.25m * 52m / 12m);
- var expected = 5 * timlon * 1.4m;
+ // AB § 20 mom. 3: kvalificerad övertid = 240 % av timlönen (månadslön / 165)
+ var timlon = 35000m / 165m;
+ var expected = 5 * (timlon * 2.4m);
Assert.Equal(expected, result.Overtidstillagg.Amount);
var overtidRad = result.Rader.First(r => r.LoneartKod == "1420");
@@ -235,9 +319,9 @@ public async Task CalculateAsync_MedJourTimmar_CorrectJourTillagg()
var result = await _engine.CalculateAsync(
_runId, _employeeId, _employmentId, 2025, 1, input);
- // Jour = timlön * 0.40 * 16
+ // Jour = timlön * 0.40 * 16, med AB-timlön = månadslön / 165
// Motorn beräknar: jourSats = timlon * 0.40, sedan jourTillagg = timmar * jourSats
- var timlon = 35000m / (38.25m * 52m / 12m);
+ var timlon = 35000m / 165m;
var jourSats = timlon * 0.40m;
var expected = 16 * jourSats;
Assert.Equal(expected, result.JourTillagg.Amount);
@@ -262,9 +346,9 @@ public async Task CalculateAsync_MedBeredskapsTimmar_CorrectBeredskapsTillagg()
var result = await _engine.CalculateAsync(
_runId, _employeeId, _employmentId, 2025, 1, input);
- // Beredskap = timlön * 0.20 * 24
- var timlon = 35000m / (38.25m * 52m / 12m);
- var expected = 24 * timlon * 0.20m;
+ // Beredskap = timlön * 0.20 * 24, med AB-timlön = månadslön / 165
+ var timlon = 35000m / 165m;
+ var expected = 24 * (timlon * 0.20m);
Assert.Equal(expected, result.BeredskapsTillagg.Amount);
var beredskapsRad = result.Rader.First(r => r.LoneartKod == "1510");
@@ -304,6 +388,71 @@ public async Task CalculateAsync_MedSjukdagar_CorrectSjuklonOchKarens()
var karensRad = result.Rader.First(r => r.LoneartKod == "3001");
Assert.True(karensRad.ArAvdrag);
Assert.True(karensRad.Belopp.Amount < 0);
+
+ // Sjukavdrag: full daglön dras för varje sjukdag (AB § 28) — utan detta skulle
+ // sjukfrånvaro HÖJA bruttot (full månadslön + sjuklön ovanpå).
+ var expectedSjukavdrag = daglon * 5;
+ var sjukavdragRad = result.Rader.First(r => r.LoneartKod == "3005");
+ Assert.True(sjukavdragRad.ArAvdrag);
+ Assert.Equal(-expectedSjukavdrag, sjukavdragRad.Belopp.Amount);
+
+ // Brutto = grundlön − sjukavdrag + sjuklön − karensavdrag → alltid LÄGRE än frisk månad
+ var expectedBrutto = 35000m - expectedSjukavdrag + expectedSjuklon - expectedKarens;
+ Assert.Equal(Math.Round(expectedBrutto, 2), Math.Round(result.Brutto.Amount, 2));
+ Assert.True(result.Brutto.Amount < 35000m,
+ "En sjukmånad ska ge lägre brutto än full månadslön");
+ }
+
+ [Fact]
+ public async Task CalculateAsync_34500Med5Sjukdagar_GerLagreBruttoAn34500()
+ {
+ // Handräknat exempel: 34 500 kr månadslön, 21 arbetsdagar, 5 sjukdagar.
+ // Daglön = 34 500 / 21 = 1 642,857...
+ // Sjukavdrag = 5 × daglön = 8 214,286
+ // Sjuklön = 5 × daglön × 80 % = 6 571,429
+ // Karens = (34 500 × 12 / 52) × 80 % × 20 % = 1 273,846
+ // Brutto = 34 500 − 8 214,286 + 6 571,429 − 1 273,846 ≈ 31 583,30 < 34 500
+ SetupStandardEmployee(manadslon: 34500m);
+ var input = new PayrollInput
+ {
+ ArbetadeDagar = 21,
+ ArbetsdagarIManadens = 21,
+ SjukdagarMedLon = 5
+ };
+
+ var result = await _engine.CalculateAsync(
+ _runId, _employeeId, _employmentId, 2025, 1, input);
+
+ var daglon = 34500m / 21m;
+ var expectedBrutto = 34500m - daglon * 5 + daglon * 0.80m * 5
+ - 34500m * 12m / 52m * 0.80m * 0.20m;
+ Assert.Equal(Math.Round(expectedBrutto, 2), Math.Round(result.Brutto.Amount, 2));
+ Assert.True(result.Brutto.Amount < 34500m,
+ "34 500 kr + 5 sjukdagar ska ge LÄGRE brutto än 34 500 kr");
+ }
+
+ [Fact]
+ public async Task CalculateAsync_SjukdagarUtanLon_DrasUtanSjuklon()
+ {
+ // Dag 15+ ersätts av Försäkringskassan: fullt löneavdrag men ingen sjuklön.
+ SetupStandardEmployee(manadslon: 35000m);
+ var input = new PayrollInput
+ {
+ ArbetadeDagar = 21,
+ ArbetsdagarIManadens = 21,
+ SjukdagarMedLon = 14,
+ SjukdagarUtanLon = 7
+ };
+
+ var result = await _engine.CalculateAsync(
+ _runId, _employeeId, _employmentId, 2025, 1, input);
+
+ var daglon = 35000m / 21m;
+ var sjukavdragRad = result.Rader.First(r => r.LoneartKod == "3005");
+ Assert.Equal(21m, sjukavdragRad.Antal); // avdrag för alla 21 sjukdagar
+ Assert.Equal(-(daglon * 21), sjukavdragRad.Belopp.Amount);
+ Assert.Equal(daglon * 0.80m * 14, result.Sjuklon.Amount); // sjuklön endast dag 2–14
+ Assert.True(result.Brutto.Amount < 35000m);
}
#endregion
@@ -530,6 +679,19 @@ public async Task CalculateAsync_MedForaldraledighet_CorrectUtfyllnad()
Assert.Equal("Föräldralöneutfyllnad", rad.Benamning);
Assert.True(rad.ArSemestergrundande);
Assert.True(rad.ArPensionsgrundande);
+
+ // Löneavdrag för den föräldralediga tiden (full daglön per dag) — utan detta
+ // skulle föräldraledighet HÖJA bruttot i stället för att sänka det.
+ var avdragRad = result.Rader.First(r => r.LoneartKod == "3210");
+ Assert.True(avdragRad.ArAvdrag);
+ Assert.Equal(-(daglon * 10), avdragRad.Belopp.Amount);
+
+ // Brutto = grundlön − avdrag + utfyllnad < full månadslön
+ Assert.Equal(
+ Math.Round(35000m - daglon * 10 + expected, 2),
+ Math.Round(result.Brutto.Amount, 2));
+ Assert.True(result.Brutto.Amount < 35000m,
+ "En föräldraledig månad ska ge lägre brutto än full månadslön");
}
#endregion
@@ -556,9 +718,9 @@ public async Task CalculateAsync_FulltScenario_CorrectBruttoOchNetto()
// Grundlön = 35000
// OB = 10 * 126.50 = 1265
- // Övertid = 5 * timlön * 0.8 (AB 25 supplement)
- var timlon = 35000m / (38.25m * 52m / 12m);
- var overtid = 5 * timlon * 0.8m;
+ // Övertid = 5 * timlön * 1.8 (AB § 20 mom. 3: hela beloppet, timlön = månadslön / 165)
+ var timlon = 35000m / 165m;
+ var overtid = 5 * (timlon * 1.8m);
// Semestertillägg = 35000 * 100/100 * 0.00605 * 3 (AB §27 mom 15)
var semestertillagg = 35000m * 100m / 100m * 0.00605m * 3;
diff --git a/tests/Payroll.Tests/SvenskaHelgdagarTests.cs b/tests/Payroll.Tests/SvenskaHelgdagarTests.cs
index c1e0577..cb72c8f 100644
--- a/tests/Payroll.Tests/SvenskaHelgdagarTests.cs
+++ b/tests/Payroll.Tests/SvenskaHelgdagarTests.cs
@@ -205,10 +205,47 @@ public void ArStorhelg_Midsommardagen_ReturnsTrue()
}
[Fact]
- public void ArStorhelg_KristiHimmelsfardsdag_ReturnsTrue()
+ public void ArStorhelg_KristiHimmelsfardsdag_ReturnsFalse()
{
- // Kristi himmelsfärdsdag 2025: 29 maj
- Assert.True(SvenskaHelgdagar.ArStorhelg(new DateOnly(2025, 5, 29)));
+ // Kristi himmelsfärdsdag 2025: 29 maj — helgdag (O-tilläggstid B) men INTE storhelg
+ // enligt AB 25 § 21 (ingår inte i O-tilläggstid A).
+ Assert.False(SvenskaHelgdagar.ArStorhelg(new DateOnly(2025, 5, 29)));
+ }
+
+ [Fact]
+ public void ArStorhelg_Langfredagen_ReturnsTrue()
+ {
+ // Långfredagen 2025: 18 april — ingår i O-tilläggstid A (AB 25 § 21).
+ Assert.True(SvenskaHelgdagar.ArStorhelg(new DateOnly(2025, 4, 18)));
+ }
+
+ [Fact]
+ public void ArStorhelg_SkartorsdagFran18_ReturnsTrue()
+ {
+ // Skärtorsdagen 2025: 17 april. A-tiden börjar kl. 18.00 dagen före långfredagen.
+ var skartorsdag = new DateOnly(2025, 4, 17);
+ Assert.True(SvenskaHelgdagar.ArStorhelg(skartorsdag, new TimeOnly(18, 0)));
+ Assert.True(SvenskaHelgdagar.ArStorhelg(skartorsdag, new TimeOnly(21, 30)));
+ Assert.False(SvenskaHelgdagar.ArStorhelg(skartorsdag, new TimeOnly(12, 0)));
+ // Dygnsnivå: skärtorsdagen som helhet är inte storhelg.
+ Assert.False(SvenskaHelgdagar.ArStorhelg(skartorsdag));
+ }
+
+ [Fact]
+ public void ArStorhelg_SondagenEfterMidsommar_ReturnsTrue()
+ {
+ // Midsommardagen 2025: 21 juni (lördag). A-tiden löper t.o.m. söndagen 22 juni
+ // (och till kl. 07.00 på måndagen).
+ Assert.True(SvenskaHelgdagar.ArStorhelg(new DateOnly(2025, 6, 22)));
+ }
+
+ [Fact]
+ public void ArStorhelg_MandagEfterMidsommarTill07_ReturnsTrue()
+ {
+ var mandag = new DateOnly(2025, 6, 23);
+ Assert.True(SvenskaHelgdagar.ArStorhelg(mandag, new TimeOnly(6, 30)));
+ Assert.False(SvenskaHelgdagar.ArStorhelg(mandag, new TimeOnly(7, 0)));
+ Assert.False(SvenskaHelgdagar.ArStorhelg(mandag));
}
[Fact]
@@ -257,13 +294,43 @@ public void BeraknaOBKategori_VardagNatt_VardagNatt()
}
[Fact]
- public void BeraknaOBKategori_VardagTidigMorgon_VardagNatt()
+ public void BeraknaOBKategori_MandagTidigMorgon_Helg()
{
- // Måndag 05:00 → VardagNatt (före 06:00)
+ // Måndag 05:00 → O-tilläggstid B (helgtiden löper till kl. 07 på måndagen, AB 25 § 21)
var datum = new DateOnly(2025, 3, 10); // Måndag
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(5, 0)));
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(6, 30)));
+ }
+
+ [Fact]
+ public void BeraknaOBKategori_TisdagTidigMorgon_VardagNatt()
+ {
+ // Tisdag 05:00 → VardagNatt (O-tilläggstid C, före 06:00)
+ var datum = new DateOnly(2025, 3, 11); // Tisdag
Assert.Equal(OBCategory.VardagNatt, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(5, 0)));
}
+ [Fact]
+ public void BeraknaOBKategori_FredagKvallEfterAB25_Helg()
+ {
+ // AB 25 (fr.o.m. 2025-04-01): fredag 17:00–24:00 är O-tilläggstid B (helg), inte D (kväll)
+ var fredag = new DateOnly(2025, 5, 9); // Fredag efter 2025-04-01
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(fredag, new TimeOnly(17, 0)));
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(fredag, new TimeOnly(20, 0)));
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(fredag, new TimeOnly(23, 0)));
+ // Före 17:00 på fredag: dagtid, ingen OB
+ Assert.Equal(OBCategory.Ingen, SvenskaHelgdagar.BeraknaOBKategori(fredag, new TimeOnly(16, 59)));
+ }
+
+ [Fact]
+ public void BeraknaOBKategori_FredagKvallForeAB25_HelgFran19()
+ {
+ // Före 2025-04-01: fredagshelgen börjar kl. 19:00
+ var fredag = new DateOnly(2025, 3, 14); // Fredag före 2025-04-01
+ Assert.Equal(OBCategory.Ingen, SvenskaHelgdagar.BeraknaOBKategori(fredag, new TimeOnly(17, 30)));
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(fredag, new TimeOnly(19, 0)));
+ }
+
[Fact]
public void BeraknaOBKategori_Lordag_Helg()
{
@@ -310,10 +377,35 @@ public void BeraknaOBKategori_Midsommarafton_Storhelg()
}
[Fact]
- public void BeraknaOBKategori_KristiHimmelsfardsdag_Storhelg()
+ public void BeraknaOBKategori_KristiHimmelsfardsdag_Helg()
{
+ // Kristi himmelsfärdsdag är helgdag → O-tilläggstid B, INTE storhelg (AB 25 § 21)
var datum = new DateOnly(2025, 5, 29); // Kristi himmelsfärdsdag 2025
- Assert.Equal(OBCategory.Storhelg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(12, 0)));
+ Assert.Equal(OBCategory.Helg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(12, 0)));
+ }
+
+ [Fact]
+ public void BeraknaOBKategori_Langfredagen_Storhelg()
+ {
+ var datum = new DateOnly(2025, 4, 18); // Långfredagen 2025
+ Assert.Equal(OBCategory.Storhelg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(10, 0)));
+ }
+
+ [Fact]
+ public void BeraknaOBKategori_SkartorsdagKvall_Storhelg()
+ {
+ // Skärtorsdagen från kl. 18.00 ingår i storhelgen (AB 25 § 21)
+ var datum = new DateOnly(2025, 4, 17);
+ Assert.Equal(OBCategory.Storhelg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(19, 0)));
+ }
+
+ [Fact]
+ public void BeraknaOBKategori_MandagEfterMidsommarFore07_Storhelg()
+ {
+ // Storhelgen efter midsommar löper till kl. 07.00 på måndagen
+ var datum = new DateOnly(2025, 6, 23);
+ Assert.Equal(OBCategory.Storhelg, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(6, 0)));
+ Assert.Equal(OBCategory.Ingen, SvenskaHelgdagar.BeraknaOBKategori(datum, new TimeOnly(9, 0)));
}
[Fact]
diff --git a/tests/RegionHR.Infrastructure.Tests/Payroll/PayrollInputBuilderTests.cs b/tests/RegionHR.Infrastructure.Tests/Payroll/PayrollInputBuilderTests.cs
index d22f08a..88dfaa7 100644
--- a/tests/RegionHR.Infrastructure.Tests/Payroll/PayrollInputBuilderTests.cs
+++ b/tests/RegionHR.Infrastructure.Tests/Payroll/PayrollInputBuilderTests.cs
@@ -5,6 +5,7 @@
using RegionHR.Leave.Domain;
using RegionHR.Scheduling.Domain;
using RegionHR.SharedKernel.Domain;
+using RegionHR.Travel.Domain;
using Xunit;
namespace RegionHR.Infrastructure.Tests.Payroll;
@@ -49,7 +50,23 @@ public async Task BuildAsync_FullMonthEmployment_CountsAllWorkingDays_AndFullBas
Assert.Equal(0, input.SemesterdagarUttagna);
Assert.Equal(Money.Zero, input.Loneutmatning);
Assert.Equal(Money.Zero, input.Fackavgift);
- Assert.Equal(employment.EnhetId.Value.ToString(), input.Kostnadsstalle);
+ // Enheten finns inte i organisationsregistret → inget kostnadsställe
+ // (tidigare skrevs enhetens GUID, 36 tecken, som sprängde varchar(20)-kolumnen).
+ Assert.Null(input.Kostnadsstalle);
+ }
+
+ [Fact]
+ public async Task BuildAsync_KostnadsstalleHamtasFranOrganisationsregistret()
+ {
+ var enhet = OrganizationUnit.Skapa(
+ "Avdelning 32", OrganizationUnitType.Avdelning, "20032", new DateOnly(2015, 1, 1));
+ _db.OrganizationUnits.Add(enhet);
+ var (_, employment) = await SeedEmployeeAsync(enhetId: enhet.Id);
+
+ var input = await _builder.BuildAsync(_db, employment, Ar, Manad);
+
+ // Kort kostnadsställeskod ur registret — ryms i payroll_result_lines.kostnadsstalle (varchar(20)).
+ Assert.Equal("20032", input.Kostnadsstalle);
}
[Fact]
@@ -151,6 +168,55 @@ public async Task BuildAsync_NoShiftOvertime_FallsBackToTimesheetOvertime()
Assert.True(input.KvalificeradOvertid);
}
+ [Fact]
+ public async Task BuildAsync_HelgpassOverMidnatt_SeparerarNattTimmar()
+ {
+ var (emp, employment) = await SeedEmployeeAsync();
+ // Lördag 5 sep 2026, 20:00–02:00 utan rast = 6 h, varav 4 h inom kl. 22–06
+ // (22–24 + 00–02). Natt-timmarna behövs för den natthöjda O-satsen (AB § 21 anm.).
+ AddShift(emp.Id, new DateOnly(Ar, Manad, 5), ShiftType.Kvall, OBCategory.Helg,
+ new TimeOnly(20, 0), new TimeOnly(2, 0), rastMin: 0, faktisk: true);
+ await _db.SaveChangesAsync();
+
+ var input = await _builder.BuildAsync(_db, employment, Ar, Manad);
+
+ var ob = Assert.Single(input.OBTimmar);
+ Assert.Equal(OBCategory.Helg, ob.Kategori);
+ Assert.Equal(6m, ob.Timmar);
+ Assert.Equal(4m, ob.NattTimmar);
+ }
+
+ // === Resekrav ===
+
+ [Fact]
+ public async Task BuildAsync_AttesteradeResekrav_LaggsTillSomResersattning()
+ {
+ var (emp, employment) = await SeedEmployeeAsync();
+
+ // Attesterat (godkänt) krav: 2 hela dagar traktamente (600), 10 mil (250), utlägg 120.
+ var godkant = TravelClaim.Skapa(emp.Id, "Konferens Stockholm", new DateOnly(Ar, Manad, 10));
+ godkant.SattTraktamente(helaDagar: 2, halvaDagar: 0);
+ godkant.SattMilersattning(10m);
+ godkant.LaggTillUtlagg("Parkering", Money.SEK(120m));
+ godkant.SkickaIn();
+ godkant.Attestera("Eva Chef");
+ _db.TravelClaims.Add(godkant);
+
+ // Endast inskickat (ej attesterat) krav ska INTE betalas ut.
+ var inskickat = TravelClaim.Skapa(emp.Id, "Kursresa", new DateOnly(Ar, Manad, 12));
+ inskickat.SattMilersattning(5m);
+ inskickat.SkickaIn();
+ _db.TravelClaims.Add(inskickat);
+
+ await _db.SaveChangesAsync();
+
+ var input = await _builder.BuildAsync(_db, employment, Ar, Manad);
+
+ Assert.Equal(600m, input.ReseTraktamente.Amount);
+ Assert.Equal(250m, input.ReseMilersattning.Amount);
+ Assert.Equal(120m, input.ReseUtlagg.Amount);
+ }
+
// === Frånvaro ===
[Fact]
@@ -187,8 +253,11 @@ public async Task BuildAsync_SickLeaveLongerThan14Days_CappedAt14()
var input = await _builder.BuildAsync(_db, employment, Ar, Manad);
- Assert.True(WorkingDays(new DateOnly(Ar, Manad, 1), new DateOnly(Ar, Manad, 30)) > 14);
+ var sjukdagar = WorkingDays(new DateOnly(Ar, Manad, 1), new DateOnly(Ar, Manad, 30));
+ Assert.True(sjukdagar > 14);
Assert.Equal(14, input.SjukdagarMedLon);
+ // Dag 15+ ger inget sjuklöneunderlag men fullt löneavdrag → redovisas separat.
+ Assert.Equal(sjukdagar - 14, input.SjukdagarUtanLon);
}
[Fact]
@@ -207,10 +276,11 @@ public async Task BuildAsync_LeavePartlyOutsidePeriod_OnlyIntersectionCounts()
// === Hjälpmetoder ===
- private async Task<(Employee Employee, Employment Employment)> SeedEmployeeAsync(DateOnly? start = null)
+ private async Task<(Employee Employee, Employment Employment)> SeedEmployeeAsync(
+ DateOnly? start = null, OrganizationId? enhetId = null)
{
var employee = Employee.Skapa(new Personnummer("198112289874"), "Test", "Testsson");
- var enhet = new OrganizationId(Guid.NewGuid());
+ var enhet = enhetId ?? new OrganizationId(Guid.NewGuid());
var employment = employee.LaggTillAnstallning(
enhet, EmploymentType.Tillsvidare, CollectiveAgreementType.AB,
Money.SEK(35000m), Percentage.FullTime, start ?? new DateOnly(2020, 1, 1));
diff --git a/tests/SalaryReview.Tests/SalaryReviewRoundTests.cs b/tests/SalaryReview.Tests/SalaryReviewRoundTests.cs
index 0511849..f4f297b 100644
--- a/tests/SalaryReview.Tests/SalaryReviewRoundTests.cs
+++ b/tests/SalaryReview.Tests/SalaryReviewRoundTests.cs
@@ -63,6 +63,36 @@ public void LaggTillForslag_overskrider_budget_kastar_exception()
"Överskrider budget"));
}
+ [Fact]
+ public void LaggTillForslag_med_lonesankning_kastar_exception_och_ror_inte_budgeten()
+ {
+ var runda = SkapaTestRunda(Money.SEK(10_000m));
+
+ var ex = Assert.Throws(() =>
+ runda.LaggTillForslag(
+ EmployeeId.New(),
+ Money.SEK(30_000m),
+ Money.SEK(28_000m),
+ "Oavsiktlig sänkning"));
+
+ Assert.Contains("lägre än nuvarande", ex.Message);
+ Assert.Empty(runda.Forslag);
+ // En negativ ökning får inte tyst frigöra budget.
+ Assert.Equal(Money.Zero, runda.FordeladBudget);
+ }
+
+ [Fact]
+ public void LaggTillForslag_med_oforandrad_lon_tillats()
+ {
+ var runda = SkapaTestRunda(Money.SEK(10_000m));
+
+ var forslag = runda.LaggTillForslag(
+ EmployeeId.New(), Money.SEK(30_000m), Money.SEK(30_000m), "Ingen förändring");
+
+ Assert.Equal(Money.Zero, forslag.Okning);
+ Assert.Equal(Money.Zero, runda.FordeladBudget);
+ }
+
[Fact]
public void Facklig_avstamning_flow_Planering_till_Genomford()
{
diff --git a/tests/Scheduling.Tests/ArbetstidslagenTests.cs b/tests/Scheduling.Tests/ArbetstidslagenTests.cs
index 36558f3..9cd073e 100644
--- a/tests/Scheduling.Tests/ArbetstidslagenTests.cs
+++ b/tests/Scheduling.Tests/ArbetstidslagenTests.cs
@@ -105,7 +105,7 @@ public void Dygnsvila_IngetForegaendePass_Godkant()
[Fact]
public void Veckovila_6ArbetstdagarIRad_7eMasteFri()
{
- // 6 dagar i rad (mån-lör), kontrollera att söndag fortfarande godkänns
+ // 6 dagar i rad (mån-lör), nytt pass på söndagen (7:e dagen) raderar veckovilan
var existing = new List();
for (int i = 0; i < 6; i++)
{
@@ -116,18 +116,23 @@ public void Veckovila_6ArbetstdagarIRad_7eMasteFri()
new TimeOnly(16, 0)));
}
- // Försök lägga till pass på söndagen (7:e dagen)
+ // Försök lägga till pass på söndagen (7:e dagen) — det NYA passet ska räknas in
+ var nyttPass = SkapaPass(_anstallId, new DateOnly(2025, 3, 23), new TimeOnly(7, 0), new TimeOnly(16, 0));
var resultat = _sut.UppfyllerVeckovila(
_anstallId,
new DateOnly(2025, 3, 23), // Söndag
- existing);
+ existing,
+ nyttPass);
Assert.False(resultat);
}
[Fact]
- public void Veckovila_5Arbetsdagar_Godkant()
+ public void Veckovila_5ArbetsdagarPlusNyttLordagspass_Underkant()
{
+ // Mån-fre befintliga pass. Ett NYTT lördagspass gör att längsta sammanhängande
+ // vilan i veckan blir lör 16:00 → mån 00:00 = 32h < 36h → brott.
+ // (Tidigare bugg: <6 pass i veckan gav alltid true och det nya passet räknades inte in.)
var existing = new List();
for (int i = 0; i < 5; i++)
{
@@ -138,11 +143,37 @@ public void Veckovila_5Arbetsdagar_Godkant()
new TimeOnly(16, 0)));
}
- // Lägga till pass på lördag (6:e dagen) — fortfarande OK
+ var nyttPass = SkapaPass(_anstallId, new DateOnly(2025, 3, 22), new TimeOnly(7, 0), new TimeOnly(16, 0));
var resultat = _sut.UppfyllerVeckovila(
_anstallId,
new DateOnly(2025, 3, 22), // Lördag
- existing);
+ existing,
+ nyttPass);
+
+ Assert.False(resultat);
+ }
+
+ [Fact]
+ public void Veckovila_4ArbetsdagarPlusNyttFredagspass_Godkant()
+ {
+ // Mån-tors befintliga pass + nytt fredagspass → lör+sön fria
+ // = fre 16:00 → mån 00:00 = 56h sammanhängande vila ≥ 36h → OK.
+ var existing = new List();
+ for (int i = 0; i < 4; i++)
+ {
+ existing.Add(SkapaPass(
+ _anstallId,
+ new DateOnly(2025, 3, 17).AddDays(i), // Mån-Tors
+ new TimeOnly(7, 0),
+ new TimeOnly(16, 0)));
+ }
+
+ var nyttPass = SkapaPass(_anstallId, new DateOnly(2025, 3, 21), new TimeOnly(7, 0), new TimeOnly(16, 0));
+ var resultat = _sut.UppfyllerVeckovila(
+ _anstallId,
+ new DateOnly(2025, 3, 21), // Fredag
+ existing,
+ nyttPass);
Assert.True(resultat);
}
diff --git a/tests/Scheduling.Tests/SchemaRedigeraTests.cs b/tests/Scheduling.Tests/SchemaRedigeraTests.cs
index 38d5a15..acaf61d 100644
--- a/tests/Scheduling.Tests/SchemaRedigeraTests.cs
+++ b/tests/Scheduling.Tests/SchemaRedigeraTests.cs
@@ -52,6 +52,68 @@ public void LaggTillPass_LaggsISchemat()
Assert.Single(schema.Pass);
Assert.Equal(pass.Id, schema.Pass[0].Id);
Assert.Equal(ShiftStatus.Planerad, pass.Status);
+ // Vardagsdagpass har ingen OB — får inte defaulta till kvälls-OB (enum-värde 0).
+ Assert.Equal(OBCategory.Ingen, pass.OBKategori);
+ }
+
+ [Fact]
+ public void LaggTillPass_VardagsDagpass_FarOBKategoriIngen()
+ {
+ // Måndag 2026-01-12 (vardag, ej helgdag) 07:00-16:00 → ingen OB.
+ var schema = Schedule.SkapaGrundschema(_enhet, "G", new DateOnly(2026, 1, 5), 4);
+
+ var pass = schema.LaggTillPass(_anstallId, new DateOnly(2026, 1, 12),
+ ShiftType.Dag, new TimeOnly(7, 0), new TimeOnly(16, 0), TimeSpan.FromMinutes(60));
+
+ Assert.Equal(OBCategory.Ingen, pass.OBKategori);
+ }
+
+ [Fact]
+ public void LaggTillPass_VardagsKvallspass_FarKvallsOB()
+ {
+ // Måndag 2026-01-12 15:00-22:00 → passets senare del (19-22) är kvälls-OB.
+ var schema = Schedule.SkapaGrundschema(_enhet, "G", new DateOnly(2026, 1, 5), 4);
+
+ var pass = schema.LaggTillPass(_anstallId, new DateOnly(2026, 1, 12),
+ ShiftType.Kvall, new TimeOnly(15, 0), new TimeOnly(22, 0), TimeSpan.FromMinutes(30));
+
+ Assert.Equal(OBCategory.VardagKvall, pass.OBKategori);
+ }
+
+ [Fact]
+ public void LaggTillPass_NattpassOverMidnatt_FarNattOB()
+ {
+ // Måndag 2026-01-12 21:00 - tisdag 07:00 → natt-OB (natt prioriteras över kväll).
+ var schema = Schedule.SkapaGrundschema(_enhet, "G", new DateOnly(2026, 1, 5), 4);
+
+ var pass = schema.LaggTillPass(_anstallId, new DateOnly(2026, 1, 12),
+ ShiftType.Natt, new TimeOnly(21, 0), new TimeOnly(7, 0), TimeSpan.FromMinutes(45));
+
+ Assert.Equal(OBCategory.VardagNatt, pass.OBKategori);
+ }
+
+ [Fact]
+ public void LaggTillPass_Helgpass_FarHelgOB()
+ {
+ // Lördag 2026-01-10 07:00-16:00 → helg-OB.
+ var schema = Schedule.SkapaGrundschema(_enhet, "G", new DateOnly(2026, 1, 5), 4);
+
+ var pass = schema.LaggTillPass(_anstallId, new DateOnly(2026, 1, 10),
+ ShiftType.Dag, new TimeOnly(7, 0), new TimeOnly(16, 0), TimeSpan.FromMinutes(60));
+
+ Assert.Equal(OBCategory.Helg, pass.OBKategori);
+ }
+
+ [Fact]
+ public void LaggTillPass_Storhelgspass_FarStorhelgsOB()
+ {
+ // Julafton 2026-12-24 07:00-16:00 → storhelgs-OB.
+ var schema = Schedule.SkapaGrundschema(_enhet, "G", new DateOnly(2026, 12, 21), 4);
+
+ var pass = schema.LaggTillPass(_anstallId, new DateOnly(2026, 12, 24),
+ ShiftType.Dag, new TimeOnly(7, 0), new TimeOnly(16, 0), TimeSpan.FromMinutes(60));
+
+ Assert.Equal(OBCategory.Storhelg, pass.OBKategori);
}
[Fact]
diff --git a/tests/Web.Tests/RouteAccessPolicyTests.cs b/tests/Web.Tests/RouteAccessPolicyTests.cs
index eaa35e9..05873f0 100644
--- a/tests/Web.Tests/RouteAccessPolicyTests.cs
+++ b/tests/Web.Tests/RouteAccessPolicyTests.cs
@@ -125,7 +125,8 @@ public void Hr_IsAllowed_ForHrRoutes(string path)
Assert.True(RouteAccessPolicy.IsAllowed(path, Hr));
}
- // ── Admin: TILLÅTEN överallt ──
+ // ── Admin: TILLÅTEN för alla registrerade rutter ──
+ // (Okända rutter nekas numera även Admin — se UnknownRoute_IsDenied_ForEveryone.)
[Theory]
[InlineData("/")]
[InlineData("/audit")]
@@ -133,12 +134,26 @@ public void Hr_IsAllowed_ForHrRoutes(string path)
[InlineData("/lon/korningar")]
[InlineData("/admin/konfiguration")]
[InlineData("/anstallda/ny")]
- [InlineData("/nagon/okand/rutt")]
+ [InlineData("/las")]
+ [InlineData("/anpassat/kundobjekt")]
public void Admin_IsAllowed_Everywhere(string path)
{
Assert.True(RouteAccessPolicy.IsAllowed(path, Admin));
}
+ // ── LAS och anpassade objekt: HR/Admin, inte Chef/Anställd ──
+ [Theory]
+ [InlineData("/las")]
+ [InlineData("/anpassat")]
+ [InlineData("/anpassat/kundobjekt")]
+ public void LasAndAnpassat_AreHrAdminOnly(string path)
+ {
+ Assert.False(RouteAccessPolicy.IsAllowed(path, Anstalld));
+ Assert.False(RouteAccessPolicy.IsAllowed(path, Chef));
+ Assert.True(RouteAccessPolicy.IsAllowed(path, Hr));
+ Assert.True(RouteAccessPolicy.IsAllowed(path, Admin));
+ }
+
// ── Känsliga person-/löneunderåtgärder: HR/Admin, inte Chef ──
[Theory]
[InlineData("/anstallda/ny")]
@@ -174,11 +189,15 @@ public void Normalization_Handles_Query_Slash_And_Case(string path)
Assert.True(RouteAccessPolicy.IsAllowed(path, Hr));
}
- // ── Okänd rutt: fail-safe = kräver minst inloggning ──
+ // ── Okänd rutt: fail-closed = NEKAS för alla tills en regel uttryckligen tillåter ──
[Fact]
- public void UnknownRoute_RequiresAuthentication()
+ public void UnknownRoute_IsDenied_ForEveryone()
{
Assert.False(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", null));
- Assert.True(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", Anstalld));
+ Assert.False(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", Anstalld));
+ Assert.False(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", Chef));
+ Assert.False(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", Hr));
+ Assert.False(RouteAccessPolicy.IsAllowed("/nagon/helt/okand/rutt", Admin));
+ Assert.Empty(RouteAccessPolicy.AllowedRolesFor("/nagon/helt/okand/rutt"));
}
}
diff --git a/tests/Wellness.Tests/RegionHR.Wellness.Tests.csproj b/tests/Wellness.Tests/RegionHR.Wellness.Tests.csproj
new file mode 100644
index 0000000..5b39680
--- /dev/null
+++ b/tests/Wellness.Tests/RegionHR.Wellness.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Wellness.Tests/WellnessClaimTests.cs b/tests/Wellness.Tests/WellnessClaimTests.cs
new file mode 100644
index 0000000..ad29356
--- /dev/null
+++ b/tests/Wellness.Tests/WellnessClaimTests.cs
@@ -0,0 +1,105 @@
+using RegionHR.Wellness.Domain;
+using Xunit;
+
+namespace RegionHR.Wellness.Tests;
+
+public class WellnessClaimTests
+{
+ private readonly Guid _anstallId = Guid.NewGuid();
+ private readonly DateOnly _datum = new(2026, 6, 15);
+
+ [Fact]
+ public void Skapa_SatterStatusInskickad()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Gymkort", 2000m, _datum);
+
+ Assert.Equal(WellnessClaimStatus.Inskickad, claim.Status);
+ Assert.Equal(2000m, claim.Belopp);
+ }
+
+ [Fact]
+ public void Skapa_KastarVidBeloppOverArstaket()
+ {
+ Assert.Throws(() =>
+ WellnessClaim.Skapa(_anstallId, "Gymkort", 5001m, _datum));
+ }
+
+ [Fact]
+ public void Skapa_TillaterExaktArstaket()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Gymkort", WellnessClaim.MaxBeloppPerAr, _datum);
+
+ Assert.Equal(5000m, claim.Belopp);
+ }
+
+ [Fact]
+ public void Skapa_KastarNarTidigareGodkantPlusBeloppSprangerTaket()
+ {
+ // 3 500 kr redan godkänt i år + 2 000 kr nytt = 5 500 kr > 5 000 kr.
+ Assert.Throws(() =>
+ WellnessClaim.Skapa(_anstallId, "Massage", 2000m, _datum,
+ tidigareGodkantUnderAret: 3500m));
+ }
+
+ [Fact]
+ public void Skapa_TillaterBeloppSomFyllerUppTillTaket()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Massage", 1500m, _datum,
+ tidigareGodkantUnderAret: 3500m);
+
+ Assert.Equal(1500m, claim.Belopp);
+ }
+
+ [Fact]
+ public void Godkann_SatterStatusOchGodkannare()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Simkort", 1000m, _datum);
+ var godkannare = Guid.NewGuid();
+
+ claim.Godkann(godkannare);
+
+ Assert.Equal(WellnessClaimStatus.Godkand, claim.Status);
+ Assert.Equal(godkannare, claim.GodkandAv);
+ Assert.NotNull(claim.GodkandVid);
+ }
+
+ [Fact]
+ public void Godkann_KastarNarArstaketSkulleOverskridas()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Personlig träning", 2000m, _datum);
+
+ Assert.Throws(() =>
+ claim.Godkann(Guid.NewGuid(), tidigareGodkantUnderAret: 3500m));
+ Assert.Equal(WellnessClaimStatus.Inskickad, claim.Status);
+ }
+
+ [Fact]
+ public void Godkann_TillaterGodkannandeUppTillTaket()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Personlig träning", 1500m, _datum);
+
+ claim.Godkann(Guid.NewGuid(), tidigareGodkantUnderAret: 3500m);
+
+ Assert.Equal(WellnessClaimStatus.Godkand, claim.Status);
+ }
+
+ [Fact]
+ public void Godkann_KastarOmRedanGodkand()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Yoga", 500m, _datum);
+ claim.Godkann(Guid.NewGuid());
+
+ Assert.Throws(() => claim.Godkann(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Avvisa_SatterStatusOchKommentar()
+ {
+ var claim = WellnessClaim.Skapa(_anstallId, "Golfklubbor", 4000m, _datum);
+
+ claim.Avvisa(Guid.NewGuid(), "Utrustning omfattas inte av friskvårdsbidraget.");
+
+ Assert.Equal(WellnessClaimStatus.Avslagen, claim.Status);
+ Assert.Equal("Utrustning omfattas inte av friskvårdsbidraget.", claim.Kommentar);
+ }
+}
|