diff --git a/CLAUDE.md b/CLAUDE.md index 7e8cf2b..e035134 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,8 +80,8 @@ Login: http://localhost:5076/login (SITHS/BankID-simulering) ## Infrastrukturtjänster ### Löneberäkning & Kollektivavtal -- `SwedishTaxCalculator` — Kommunalskatt (32.13%), statlig skatt (>51 158 kr/mån), arbetsgivaravgift (31.42%), reducerad avgift (66+) -- `KollektivavtalEngine` — OB-tillägg (kväll 46 kr/h, natt 113, helg 55, storhelg 130), viloRegler (11h), övertid (180%/240%), semester per ålder +- `SwedishTaxCalculator` — Kommunalskatt per kommun via `KommunSkattesatser` (default Örebro 2026: 33,65%), statlig skatt 20% över skiktgränsen 643 000 kr/år (≈53 583 kr/mån), arbetsgivaravgift 31,42% (äldre endast ålderspensionsavgift 10,21%, född 1937 eller tidigare 0%) via `Arbetsgivaravgift`-domänen +- `KollektivavtalEngine` — legacy-hjälpare som delegerar till de kanoniska, årsversionerade AB-tabellerna (`ABOTillaggSatser`, `ABOvertidSatser`, `ABSemesterRegler`): OB enligt AB § 21 (fr.o.m. 2026-04-01: kväll 26,40 kr/h, natt 58,40, helg 68,10, storhelg 130,70; förhöjd nattsats kl 22–06 för helg 78,30 och storhelg 156,90; fr.o.m. 2025-04-01: 25,60/56,70/66,10/126,90), viloRegler (11h), övertid (180%/240%), semester per ålder ### Schemaoptimering - `SchemaOptimizer` — Round-robin tilldelning med balansindex, per-person timmespårning diff --git a/Directory.Packages.props b/Directory.Packages.props index b118cc2..f15a58e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,7 +28,9 @@ + + diff --git a/README.md b/README.md index 6d5f55f..b24a8d1 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,26 @@ OpenHR är ett personalhanteringssystem byggt för att ersätta HEROMA och andra proprietära HR-system inom svensk offentlig sektor. Byggt med öppen källkod (AGPL-3.0), svensk arbetsrätt, kollektivavtal och GDPR. -> **OpenHR 2.2** — 38 moduler, 214 sidor, 240 domänfiler, **2 165 tester (alla gröna)**, 329/330 i18n-nycklar (sv+en). Bygg: `dotnet test RegionHR.sln`. +> **OpenHR 2.2** — 38 moduler, 214 sidor, 240 domänfiler, **2 219 tester (alla gröna)**, 329/330 i18n-nycklar (sv+en). Bygg: `dotnet test RegionHR.sln`. + +## Oberoende granskning & rättningar (2026-07-02) + +En oberoende, kritisk granskning gjordes mot Region Örebro läns upphandlingsunderlag (dnr 25RS1678), följt av en rättningsomgång. Granskningen bekräftade en genuin, domänmodellerad kärna och en stor testsvit — men fann också att flera UI-flöden såg ut att lyckas utan att spara, att lönemotorn räknade fel i vissa fall, och att denna README tidigare översålde. Rättningsomgången (bygge grönt, **2 219 tester**) åtgärdade bl.a.: + +- **Lön:** frånvaroavdrag saknades (sjukdom/föräldraledighet höjde bruttolönen) — nu korrekt; övertid enligt AB §20 (180/240 % av mån.lön/165); O-tillägg natt mot helg 2026 rättad till 101,80 kr/h (+30 %, AB §21 anm. 1); OB-tidsklassificering/storhelg mot AB §21; natthöjda satser tillämpas nu i kedjan schema→lön; godkända resekrav når lönekörningen. +- **Två live-krascher:** att skapa en lönekörning och att granska en tidrapport kraschade — nu åtgärdade (rotorsak: kostnadsställe-GUID mot `varchar(20)`-kolumn resp. value-object i en LINQ-`Where`). +- **Behörighet & spårbarhet:** åtkomstpolicyn är nu fail-closed (okänd rutt nekas; `/las`, `/anpassat` regelsatta); granskningsloggen loggar den faktiska användaren (inte "system"). +- **Övrigt:** OB-default-bugg på manuellt inlagda pass, fem "sparar-aldrig"-buggar (passbyte, öppet pass, chef-ledighet, friskvård, kompetens/utbildning), LAS-dagar räknas upp löpande + SÄVA/vikariat separat, riktig PDF (QuestPDF), rätt arbetsgivare (Region Örebro län) i dokument, schemalagda rapporter körs, m.m. + +**Fortfarande en demonstrator — inte produktionsklar.** Medvetet ej åtgärdat (kräver avtal eller större omskrivning): effektivdaterad lönehistorik-versionering, riktig EF-migrationsstrategi (kör i dag `EnsureCreated`), lasttest/utskalning för 11 000 användare, komplett A-tidsmodell för alla storhelgskanter, och de avtalsbundna live-integrationerna (BankID/SITHS, bank, FK, KPA, Navet, Inera, Health Connect). Se granskningsrapporten för exakt status per delprojekt och integration. ## Status & begränsningar (läs först) OpenHR är en **fungerande, testad demonstrator** — inte en skarp driftsatt Heroma-ersättare än, men täcker nu regionens nio funktionella delprojekt OCH större delen av integrationslandskapet (IT-arkitektur "Personalstöd v7"). Vad som är på riktigt vs demo: -- **På riktigt:** löneberäkning med korrekta 2026-värden (skattetabell, AB O-tillägg, arbetsgivaravgift, traktamente); hela HR-livscykeln (anställa→schema→tid→lön→utbetalningsfil); rollbaserad URL-behörighet (Anställd når aldrig lön/audit/admin); löneflöden (utmätning/fackavgift, tolkersättning, ersättning förtroendevalda); AKAP-KR-pension; LAS auto-kedja; behovsstyrd vårdschema-automatik; e-arkiv (arkivlagen); SCORM/e-learning; web push; BI/DW-export + realtids-beslutsstöd; KLASSA-informationsklassning; **integrationsramverk** (register + SFTP-transport + jobb-runner + körningslogg + övervakning) med filgeneratorer/adaptrar för AGI, pain.001, KPA-pension, FK-anmälan, SIE/Raindance-kontering, folkbokföring-import, KOLL-export, SCB/SKR-statistik. 2 165 gröna tester. +- **På riktigt:** löneberäkning med korrekta 2026-värden (skattetabell, AB O-tillägg, arbetsgivaravgift, traktamente); hela HR-livscykeln (anställa→schema→tid→lön→utbetalningsfil); rollbaserad URL-behörighet (Anställd når aldrig lön/audit/admin); löneflöden (utmätning/fackavgift, tolkersättning, ersättning förtroendevalda); AKAP-KR-pension; LAS auto-kedja; behovsstyrd vårdschema-automatik; e-arkiv (arkivlagen); SCORM/e-learning; web push; BI/DW-export + realtids-beslutsstöd; KLASSA-informationsklassning; **integrationsramverk** (register + SFTP-transport + jobb-runner + körningslogg + övervakning) med filgeneratorer/adaptrar för AGI, pain.001, KPA-pension, FK-anmälan, SIE/Raindance-kontering, folkbokföring-import, KOLL-export, SCB/SKR-statistik. 2 219 gröna tester. - **Demo/simulering (kräver externa avtal för skarp drift):** inloggning (BankID/SITHS simulerad; Entra/OIDC är config-ready via `appsettings` "Oidc"); HSA-katalogen (sandbox); skarp nätverkstransport till bank/Försäkringskassan/KPA/Skatteverket-Navet/Inera/Health Connect — filformaten är byggda + config-ready, bara den avtalsbundna kopplingen fattas. Se `docs/drift-reservloneplan.md`. -- **Återstår för produktion:** horisontell skalning för 11 000 användare (lasttest: ~1,1 MiB/circuit, median 14 ms → en 2 GB-instans klarar ~1 500 samtidiga; 11k = 2–4 instanser bakom lastbalanserare), skarpa integrationer (avtal), formell KLASSA-riskanalys, drift-SLA. Se [gap-analys](docs/gap-analysis-enterprise-hr.md). +- **Återstår för produktion:** horisontell skalning för 11 000 användare kräver **lasttest och utskalning bakom lastbalanserare** — flera en-instans-beroenden (delade Data Protection-nycklar, SignalR-backplane, delad fillagring, jobb-koordinering) återstår att adressera innan repliker kan läggas till; riktig EF-migrationsstrategi (kör i dag `EnsureCreated`); skarpa integrationer (avtal); formell KLASSA-riskanalys; drift-SLA. Se [gap-analys](docs/gap-analysis-enterprise-hr.md). ## Funktionsstatus @@ -20,7 +31,7 @@ OpenHR är en **fungerande, testad demonstrator** — inte en skarp driftsatt He Riktiga beräkningar med svensk lagstiftning, anropade direkt från lönekörningen: - **PayrollCalculationEngine** — brutto→netto via seedad **skattetabell** (Skatteverket tabell 34); arbetsgivaravgift 31,42 % (äldre 10,21 % för födda ≤1958; 1937− = 0 %; temporär ungdomsnedsättning 20,81 %); statlig skatt över skiktgräns 643 000 kr/år -- **CollectiveAgreementRulesEngine** — AB §21 O-tillägg (kväll 25,60 / natt 56,70 / helg 66,10 / storhelg 126,90 kr/h), övertid §20, semester §27 per ålder; årsversionerat +- **CollectiveAgreementRulesEngine** — AB §21 O-tillägg, årsversionerat (fr.o.m. 2026-04-01: kväll 26,40 / natt 58,40 / helg 68,10 / storhelg 130,70 kr/h; natthöjt 22–06 mot helg 101,80 / storhelg 156,90); övertid §20 (180/240 % av mån.lön/165), semester §27 per ålder - **TraktamentsCalculator** — inrikes 300/150 kr + måltidsavdrag enligt Skatteverket 2026 (SKV 354); årsversionerat - **ConstraintScheduleSolver + ArbetstidslagenValidator** — schemaoptimering med ATL-kontroll (dygnsvila 11h, veckovila 36h) - **Integrationsformat** — AGI-XML (Skatteverket SKV 269, fältkod + specifikationsnummer), pain.001.001.03 (ISO 20022, riktiga person-/bankuppgifter), nedladdas via /lon/export @@ -104,7 +115,7 @@ Alla rapportvyer läser från verklig DB-data: - **EU Pay Transparency** — lönetransparensrapportering enligt EU-direktivet 2023/970, pay gap-analys per kohort ### Auth & personalisering -- **Rollbaserad session** (ClaimsPrincipal via AuthenticationStateProvider) med central path→roll-policy — Anställd når aldrig lön/audit/admin ens via direkt-URL +- **Rollbaserad session** (ClaimsPrincipal via AuthenticationStateProvider) med central path→roll-policy, **fail-closed** (okänd rutt nekas tills en regel uttryckligen tillåter) — Anställd når aldrig lön/audit/admin ens via direkt-URL - **Demo-inloggning** med profilval (Anna/Anställd, Eva/Chef, Karl/HR, Admin); **Entra/OIDC config-ready** för skarp federation - **MinSida** — schema, lön, ledighet, ärenden, profil, lönespecifikationer, stämpling, saldon - **Chefsportal** — teamvy filtrerad på chefens enhet (org-scoping), frånvarokalender, godkännanden @@ -130,7 +141,7 @@ Dessa kräver betalda avtal eller livekopplingar som regionen tecknar — filfor - Riktig BankID/SITHS-legitimation (nuvarande inloggning är demo-simulering; Entra/OIDC är config-ready) - Skarp nätverkstransport till bank, Försäkringskassan, KPA, Skatteverket/Navet, Inera/HSA och RÖL:s Health Connect - Native mobilapp (PWA med offline-stöd och push-notiser används i stället) -- Horisontell skalning för 11 000 samtidiga användare (driftbeslut: 2–4 instanser bakom lastbalanserare) +- Horisontell skalning för 11 000 samtidiga användare (kräver lasttest + utskalning bakom lastbalanserare; en-instans-beroenden återstår att adressera) ## Tech Stack @@ -184,7 +195,7 @@ Nyckelentities (urval): Employee, Employment, OrganizationUnit, PayrollRun, Payr ```bash dotnet build RegionHR.sln # 0 errors -dotnet test RegionHR.sln # 2 165 tester, 0 failures +dotnet test RegionHR.sln # 2 219 tester, 0 failures dotnet run --project src/Web/RegionHR.Web.csproj ``` diff --git a/docker-compose.yml b/docker-compose.yml index ef9d438..360eef6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,10 +43,13 @@ services: build: . container_name: regionhr-app ports: - - "5076:8080" + # Appen lyssnar på 5076 i containern (Dockerfile ASPNETCORE_URLS) — inte 8080. + - "5076:5076" environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__RegionHR=Host=db;Port=5432;Database=openhr;Username=postgres;Password=postgres + # Demon SKA seedas med demodata (default är false så en produktions-DB inte fylls av misstag). + - SeedDemoData=true depends_on: db: condition: service_healthy diff --git a/src/Infrastructure/Analytics/BeslutsstodKpiService.cs b/src/Infrastructure/Analytics/BeslutsstodKpiService.cs index 1e454f1..dd7c753 100644 --- a/src/Infrastructure/Analytics/BeslutsstodKpiService.cs +++ b/src/Infrastructure/Analytics/BeslutsstodKpiService.cs @@ -14,6 +14,8 @@ namespace RegionHR.Infrastructure.Analytics; /// KPI:er: /// • Personalomsättning % — avslutade anställningar senaste 12 mån / aktivt headcount. /// • Sjukfrånvaro % — sjukfrånvarodagar senaste 12 mån / (headcount × arbetsdagar). +/// Endast godkänd/inskickad frånvaro räknas (Utkast/Avslagen/ +/// Återkallad exkluderas). /// • Bemanningsgrad % — tillsatta positioner / bemanningsbara positioner. /// • LAS-riskantal — aktiva tidsbegränsade anställningar nära LAS-omvandlingsgräns. /// • Lönekostnad/enhet — arbetskraftskostnad (brutto + arbetsgivaravgift) per månad och per FTE. @@ -92,6 +94,8 @@ public static BeslutsstodResultat Berakna( foreach (var lr in leaveRequests) { if (lr.Typ != LeaveType.Sjukfranvaro) continue; + // Endast godkänd (eller inskickad, ännu ej behandlad) frånvaro är KPI-grundande. + if (lr.Status is not (LeaveRequestStatus.Godkand or LeaveRequestStatus.Inskickad)) continue; if (lr.FranDatum < fonsterStart || lr.FranDatum > snapshotDatum) continue; if (!empById.TryGetValue(lr.AnstallId, out var emp)) continue; diff --git a/src/Infrastructure/BackgroundJobs/CertificationReminderService.cs b/src/Infrastructure/BackgroundJobs/CertificationReminderService.cs index dc892ae..34655d5 100644 --- a/src/Infrastructure/BackgroundJobs/CertificationReminderService.cs +++ b/src/Infrastructure/BackgroundJobs/CertificationReminderService.cs @@ -1,14 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using RegionHR.Competence.Domain; +using RegionHR.Infrastructure.Persistence; +using RegionHR.Notifications.Domain; +using RegionHR.SharedKernel.Domain; namespace RegionHR.Infrastructure.BackgroundJobs; +/// +/// Bakgrundsjobb som en gång per dygn bevakar certifieringar/legitimationer vars +/// giltighet löper ut inom 90/60/30 dagar och skapar påminnelsenotiser till den +/// anställde samt dennes chef (via enhetens ChefId), i linje med . +/// +/// Notiser avdupliceras per certifiering och tröskel via RelatedEntityType/RelatedEntityId, +/// så varje tröskel (90/60/30) larmar exakt en gång per certifiering och mottagare. +/// public class CertificationReminderService : BackgroundService { + /// Påminnelsetrösklar i dagar före utgång, i stigande ordning. + internal static readonly int[] TroskelDagar = [30, 60, 90]; + + private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly TimeSpan _interval = TimeSpan.FromHours(24); - public CertificationReminderService(ILogger logger) + public CertificationReminderService(IServiceScopeFactory scopeFactory, ILogger logger) { + _scopeFactory = scopeFactory; _logger = logger; } @@ -19,14 +39,104 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { _logger.LogInformation("CertificationReminderService: Checking expiring certifications"); - // In production: query DB for certifications expiring within 30/60/90 days - // and create notifications for the employee and their manager + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var skapade = await KontrolleraCertifieringarAsync(db, stoppingToken); + _logger.LogInformation( + "CertificationReminderService: {Count} certifieringsnotiser skapade", skapade); } catch (Exception ex) { _logger.LogWarning(ex, "Error checking certifications"); } - await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + await Task.Delay(_interval, stoppingToken); + } + } + + /// + /// Kör en bevakningsomgång mot en given DbContext. Intern för testbarhet. + /// Returnerar antalet skapade notifieringar. + /// + internal async Task KontrolleraCertifieringarAsync(RegionHRDbContext db, CancellationToken ct) + { + var idag = DateOnly.FromDateTime(DateTime.Today); + var maxDatum = idag.AddDays(TroskelDagar.Max()); + + var utgaende = await db.Certifications.AsNoTracking() + .Where(c => c.GiltigTill != null && c.GiltigTill >= idag && c.GiltigTill <= maxDatum) + .ToListAsync(ct); + + var skapade = 0; + + foreach (var cert in utgaende) + { + var dagarKvar = cert.GiltigTill!.Value.DayNumber - idag.DayNumber; + var troskel = TroskelDagar.First(t => dagarKvar <= t); // 30, 60 eller 90 + + var anstalldId = EmployeeId.From(cert.AnstallId); + var anstalld = await db.Employees + .Include(e => e.Anstallningar) + .FirstOrDefaultAsync(e => e.Id == anstalldId, ct); + + // Mottagare: den anställde själv + dennes chef (om en kan härledas via enheten). + var mottagare = new List { cert.AnstallId }; + var aktivAnstallning = anstalld?.Anstallningar.FirstOrDefault(a => a.Giltighetsperiod.IsActiveOn(idag)) + ?? anstalld?.Anstallningar.OrderByDescending(a => a.Giltighetsperiod.Start).FirstOrDefault(); + if (aktivAnstallning is not null) + { + var enhet = await db.OrganizationUnits + .FirstOrDefaultAsync(u => u.Id == aktivAnstallning.EnhetId, ct); + if (enhet?.ChefId is { } chef && chef.Value != cert.AnstallId) + mottagare.Add(chef.Value); + } + + var namn = anstalld is null ? "okänd anställd" : $"{anstalld.Fornamn} {anstalld.Efternamn}"; + var relType = $"CertReminder-{troskel}"; + var relId = cert.Id.ToString(); + var (titel, meddelande, typ) = ByggMeddelande(cert, namn, dagarKvar, troskel); + + foreach (var userId in mottagare) + { + var redanNotifierad = await db.Notifications.AnyAsync(n => + n.UserId == userId && + n.RelatedEntityType == relType && + n.RelatedEntityId == relId, ct); + if (redanNotifierad) + continue; + + db.Notifications.Add(Notification.Create( + userId, + titel, + meddelande, + typ, + NotificationChannel.InApp, + actionUrl: "/kompetens", + relatedEntityType: relType, + relatedEntityId: relId)); + skapade++; + } } + + if (skapade > 0) + await db.SaveChangesAsync(ct); + + return skapade; } + + private static (string Titel, string Meddelande, NotificationType Typ) ByggMeddelande( + Certification cert, string namn, int dagarKvar, int troskel) => troskel switch + { + 30 => ( + $"Certifiering löper ut snart: {cert.Namn}", + $"{namn}: certifieringen \"{cert.Namn}\" löper ut {cert.GiltigTill:yyyy-MM-dd} ({dagarKvar} dagar kvar). Boka förnyelse omgående.", + NotificationType.Warning), + 60 => ( + $"Certifiering löper ut inom 60 dagar: {cert.Namn}", + $"{namn}: certifieringen \"{cert.Namn}\" löper ut {cert.GiltigTill:yyyy-MM-dd} ({dagarKvar} dagar kvar). Planera förnyelse.", + NotificationType.Reminder), + _ => ( + $"Certifiering löper ut inom 90 dagar: {cert.Namn}", + $"{namn}: certifieringen \"{cert.Namn}\" löper ut {cert.GiltigTill:yyyy-MM-dd} ({dagarKvar} dagar kvar).", + NotificationType.Reminder) + }; } diff --git a/src/Infrastructure/BackgroundJobs/LASAlertService.cs b/src/Infrastructure/BackgroundJobs/LASAlertService.cs index 72c4e95..cd776ee 100644 --- a/src/Infrastructure/BackgroundJobs/LASAlertService.cs +++ b/src/Infrastructure/BackgroundJobs/LASAlertService.cs @@ -3,8 +3,10 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RegionHR.Infrastructure.Persistence; +using RegionHR.LAS.Domain; using RegionHR.LAS.Services; using RegionHR.Notifications.Domain; +using RegionHR.SharedKernel.Domain; namespace RegionHR.Infrastructure.BackgroundJobs; @@ -13,6 +15,10 @@ namespace RegionHR.Infrastructure.BackgroundJobs; /// (SAVA 365 / vikariat 730 dagar) och skapar notifieringar till HR och den anställdes chef /// när trösklarna 300/330/350/360 dagar (SAVA) passeras. /// +/// Varje omgång börjar med att ackumuleringarna räknas OM mot dagens datum +/// (LASService.KorDagligKontrollAsync) så att dagarna växer av tidens gång och +/// auto-konvertering triggar även utan att någon period registreras eller ändras. +/// /// Larmet går ALDRIG till den anställde själv — det är HR/chef som ska agera på en /// annalkande konvertering (formbyte till tillsvidare). Notifieringar avdupliceras per /// nivå+tröskel inom ett dygnsfönster så samma larm inte upprepas varje körning. @@ -38,7 +44,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("LASAlertService: kontrollerar LAS-trösklar"); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var skapade = await KontrolleraTrosklarAsync(db, stoppingToken); + var lasService = scope.ServiceProvider.GetRequiredService(); + var skapade = await KontrolleraTrosklarAsync(db, lasService, stoppingToken); _logger.LogInformation("LASAlertService: {Count} LAS-notifieringar skapade", skapade); } catch (Exception ex) @@ -54,11 +61,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// Kör en bevakningsomgång mot en given DbContext. Intern för testbarhet. /// Returnerar antalet skapade notifieringar. /// - internal async Task KontrolleraTrosklarAsync(RegionHRDbContext db, CancellationToken ct) + internal async Task KontrolleraTrosklarAsync(RegionHRDbContext db, LASService lasService, CancellationToken ct) { var idag = DateOnly.FromDateTime(DateTime.Today); var cutoff = DateTime.UtcNow.AddHours(-20); + // Omberäkna FÖRST alla ackumuleringar mot dagens datum. Utan detta skulle + // lagrade AckumuleradeDagar aldrig växa av tidens gång — larm och + // auto-konvertering skulle bara trigga när en period registreras/ändras. + await lasService.KorDagligKontrollAsync(ct); + var ackumuleringar = await db.LASAccumulations.AsNoTracking().ToListAsync(ct); // HR-mottagare: anställda vars befattning anger HR-funktion. @@ -72,7 +84,9 @@ internal async Task KontrolleraTrosklarAsync(RegionHRDbContext db, Cancella foreach (var acc in ackumuleringar) { - var bedomning = LASAlertRegler.Bedom(acc.Anstallningsform, acc.AckumuleradeDagar); + // SAVA- och vikariatstid ackumuleras separat — bedöm varje form mot sin + // egen gräns (365 resp. 730) och larma på den allvarligaste nivån. + var (bedomning, dagarForForm) = BedomVarstaForm(acc); if (bedomning.Niva == LASAlertNiva.Ingen) continue; @@ -103,7 +117,7 @@ internal async Task KontrolleraTrosklarAsync(RegionHRDbContext db, Cancella var namn = anstalld is null ? "okänd anställd" : $"{anstalld.Fornamn} {anstalld.Efternamn}"; var relType = $"LAS-HRAlert-{bedomning.Niva}-{bedomning.TroskelDagar}"; var relId = acc.Id.ToString(); - var (titel, meddelande, typ) = ByggMeddelande(bedomning, namn, acc.AckumuleradeDagar); + var (titel, meddelande, typ) = ByggMeddelande(bedomning, namn, dagarForForm); foreach (var userId in mottagare) { @@ -134,6 +148,19 @@ internal async Task KontrolleraTrosklarAsync(RegionHRDbContext db, Cancella return skapade; } + /// + /// Bedöm SAVA- och vikariatsdagarna var för sig mot respektive gräns och + /// returnera den allvarligaste bedömningen tillsammans med formens dagantal. + /// + private static (LASAlertBedomning Bedomning, int Dagar) BedomVarstaForm(LASAccumulation acc) + { + var sava = LASAlertRegler.Bedom(EmploymentType.SAVA, acc.AckumuleradeSavaDagar); + var vikariat = LASAlertRegler.Bedom(EmploymentType.Vikariat, acc.AckumuleradeVikariatDagar); + return sava.Niva >= vikariat.Niva + ? (sava, acc.AckumuleradeSavaDagar) + : (vikariat, acc.AckumuleradeVikariatDagar); + } + private static (string Titel, string Meddelande, NotificationType Typ) ByggMeddelande( LASAlertBedomning b, string namn, int dagar) => b.Niva switch { diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index e889ed8..db2e6e2 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -40,18 +40,24 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); - // Audit interceptor - var auditInterceptor = new AuditInterceptor(); + // Audit interceptor — scoped så att ICurrentUser (registreras i webblagret) kan + // injiceras och granskningsposter stämplas med den faktiska användaren. + // När ICurrentUser inte är registrerad (API:t, tester) använder DI + // konstruktorns default (null) → stämpeln blir "system" som tidigare. + services.AddScoped(); // DbContext if (useInMemory) { services.AddDbContext((sp, options) => options.UseInMemoryDatabase("RegionHR-Dev") - .AddInterceptors(auditInterceptor, sp.GetRequiredService())); + .AddInterceptors(sp.GetRequiredService(), sp.GetRequiredService())); - services.AddDbContextFactory(options => - options.UseInMemoryDatabase("RegionHR-Dev"), ServiceLifetime.Scoped); + // Factory-contexterna (de flesta Blazor-sidorna) MÅSTE ha samma interceptors, + // annars auditloggas/dispatchas inget för ändringar gjorda via IDbContextFactory. + services.AddDbContextFactory((sp, options) => + options.UseInMemoryDatabase("RegionHR-Dev") + .AddInterceptors(sp.GetRequiredService(), sp.GetRequiredService()), ServiceLifetime.Scoped); } else { @@ -66,13 +72,16 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi { npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "public"); }) - .AddInterceptors(auditInterceptor, sp.GetRequiredService())); + .AddInterceptors(sp.GetRequiredService(), sp.GetRequiredService())); - services.AddDbContextFactory(options => + // Factory-contexterna (de flesta Blazor-sidorna) MÅSTE ha samma interceptors, + // annars auditloggas/dispatchas inget för ändringar gjorda via IDbContextFactory. + services.AddDbContextFactory((sp, options) => options.UseNpgsql(dataSource, npgsql => { npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "public"); - }), ServiceLifetime.Scoped); + }) + .AddInterceptors(sp.GetRequiredService(), sp.GetRequiredService()), ServiceLifetime.Scoped); } // Repositories diff --git a/src/Infrastructure/Export/PdfGenerator.cs b/src/Infrastructure/Export/PdfGenerator.cs index c917c20..b0bd0c8 100644 --- a/src/Infrastructure/Export/PdfGenerator.cs +++ b/src/Infrastructure/Export/PdfGenerator.cs @@ -1,122 +1,225 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using RegionHR.Core.Contracts; + namespace RegionHR.Infrastructure.Export; +/// +/// Genererar riktiga PDF-dokument (lönespecifikation, tjänstgöringsintyg, anställningsavtal) +/// via QuestPDF (Community-licens). Anställningsavtalet byggs på den lagstadgade +/// 6 c §-informationen från . +/// public class PdfGenerator { - private const string Ruler = "────────────────────────────────────────────────────"; - private const string DblRuler = "════════════════════════════════════════════════════"; + public const string ArbetsgivareNamn = "Region Örebro län"; + public const string ArbetsgivareOrgnr = "232100-0164"; + public const string ArbetsgivareAdress = "Box 1613, 701 16 Örebro"; + + static PdfGenerator() + { + QuestPDF.Settings.License = LicenseType.Community; + } public byte[] GenerateLonespecifikation(LonespecData data) { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(DblRuler); - sb.AppendLine(" LÖNESPECIFIKATION"); - sb.AppendLine(" Västra Götalandsregionen — OpenHR"); - sb.AppendLine(DblRuler); - sb.AppendLine(); - sb.AppendLine($" Namn: {data.Namn}"); - sb.AppendLine($" Personnummer: {data.Personnummer}"); - sb.AppendLine($" Period: {data.Period}"); - sb.AppendLine($" Utbetalningsdag: {data.Utbetalningsdag}"); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine(" INKOMSTER"); - sb.AppendLine(Ruler); - sb.AppendLine($" Grundlön {data.Grundlon,12:N0} kr"); - sb.AppendLine($" OB-tillägg {data.OBTillagg,12:N0} kr"); - sb.AppendLine($" Övertid {data.Overtid,12:N0} kr"); - sb.AppendLine(Ruler); - sb.AppendLine($" BRUTTO {data.Brutto,12:N0} kr"); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine(" AVDRAG"); - sb.AppendLine(Ruler); - sb.AppendLine($" Kommunalskatt -{data.KommunalSkatt,11:N0} kr"); - sb.AppendLine($" Statlig skatt -{data.StatligSkatt,11:N0} kr"); - sb.AppendLine(Ruler); - sb.AppendLine($" NETTO {data.Netto,12:N0} kr"); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine($" Arbetsgivaravgift {data.Arbetsgivaravgift,12:N0} kr"); - sb.AppendLine(DblRuler); - sb.AppendLine(); - sb.AppendLine(" PDF-generering kräver QuestPDF-paketet."); - sb.AppendLine(" Installera med: dotnet add package QuestPDF"); - return System.Text.Encoding.UTF8.GetBytes(sb.ToString()); + return SkapaDokument("LÖNESPECIFIKATION", col => + { + UppgiftsTabell(col, + ("Namn", data.Namn), + ("Personnummer", data.Personnummer), + ("Period", data.Period), + ("Utbetalningsdag", data.Utbetalningsdag)); + + Sektionsrubrik(col, "Inkomster"); + BeloppTabell(col, + ("Grundlön", data.Grundlon, false), + ("OB-tillägg", data.OBTillagg, false), + ("Övertid", data.Overtid, false), + ("Brutto", data.Brutto, true)); + + Sektionsrubrik(col, "Avdrag"); + BeloppTabell(col, + ("Kommunalskatt", -data.KommunalSkatt, false), + ("Statlig skatt", -data.StatligSkatt, false), + ("Netto", data.Netto, true)); + + Sektionsrubrik(col, "Arbetsgivarens kostnader"); + BeloppTabell(col, + ("Arbetsgivaravgift", data.Arbetsgivaravgift, false)); + }); } public byte[] GenerateTjanstgoringsintyg(TjanstgoringsintyData data) { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(DblRuler); - sb.AppendLine(" TJÄNSTGÖRINGSINTYG"); - sb.AppendLine(" Västra Götalandsregionen — OpenHR"); - sb.AppendLine(DblRuler); - sb.AppendLine(); - sb.AppendLine($" Härmed intygas att {data.Namn} ({data.Personnummer})"); - sb.AppendLine($" har varit anställd hos {data.Arbetsgivare}"); - sb.AppendLine($" under perioden {data.StartDatum} — {data.SlutDatum}."); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine(" ANSTÄLLNINGSUPPGIFTER"); - sb.AppendLine(Ruler); - sb.AppendLine($" Befattning: {data.Befattning}"); - sb.AppendLine($" Anställningsform: {data.Anstallningsform}"); - sb.AppendLine($" Sysselsättningsgrad: {data.Sysselsattningsgrad}"); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine($" Utfärdat: {DateTime.Today:yyyy-MM-dd}"); - sb.AppendLine($" Av: HR-avdelningen"); - sb.AppendLine(); - sb.AppendLine(); - sb.AppendLine(" ___________________________"); - sb.AppendLine(" Arbetsgivare"); - sb.AppendLine(DblRuler); - sb.AppendLine(); - sb.AppendLine(" PDF-generering kräver QuestPDF-paketet."); - sb.AppendLine(" Installera med: dotnet add package QuestPDF"); - return System.Text.Encoding.UTF8.GetBytes(sb.ToString()); + return SkapaDokument("TJÄNSTGÖRINGSINTYG", col => + { + col.Item().Text( + $"Härmed intygas att {data.Namn} ({data.Personnummer}) " + + $"har varit anställd hos {data.Arbetsgivare} " + + $"under perioden {data.StartDatum} — {data.SlutDatum}."); + + Sektionsrubrik(col, "Anställningsuppgifter"); + UppgiftsTabell(col, + ("Befattning", data.Befattning), + ("Anställningsform", data.Anstallningsform), + ("Sysselsättningsgrad", data.Sysselsattningsgrad)); + + col.Item().PaddingTop(14).Text($"Utfärdat: {DateTime.Today:yyyy-MM-dd}"); + col.Item().Text("Av: HR-avdelningen"); + + Underskrifter(col, "Arbetsgivare"); + }); + } + + /// + /// Genererar anställningsavtal med den kompletta skriftliga informationen enligt + /// LAS 6 c § (semester, arbetstid, övertid, uppsägningstid, social trygghet m.m.) + /// via . + /// + public byte[] GenerateAnstallningsavtal(AnstallningsavtalUppgifter uppgifter) + { + var avsnitt = AnstallningsavtalGenerator.Skapa6cInformation(uppgifter); + + return SkapaDokument("ANSTÄLLNINGSAVTAL", col => + { + col.Item().Text( + "Skriftlig information om anställningsvillkor enligt 6 c § lagen (1982:80) " + + "om anställningsskydd (LAS).") + .FontSize(9).Italic().FontColor(Colors.Grey.Darken1); + + var nr = 1; + foreach (var a in avsnitt) + { + Sektionsrubrik(col, $"{nr}. {a.Rubrik}"); + col.Item().Text(a.Text); + nr++; + } + + Sektionsrubrik(col, $"{nr}. Underskrifter"); + col.Item().Text($"Datum: {DateTime.Today:yyyy-MM-dd}"); + Underskrifter(col, "Arbetsgivare", "Arbetstagare"); + }); + } + + /// + /// Generisk PDF för enklare mallar (t.ex. bekräftelse på löneändring): + /// renderar den färdiga malltexten som ett dokument med standardhuvud/-fot. + /// + public byte[] GenerateEnkeltDokument(string titel, string text) + { + return SkapaDokument(titel.ToUpperInvariant(), col => + { + foreach (var stycke in text.Split("\n\n", StringSplitOptions.RemoveEmptyEntries)) + { + col.Item().Text(stycke.Trim()); + } + }); + } + + // ---------- Interna byggstenar ---------- + + private static byte[] SkapaDokument(string titel, Action innehall) + { + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(2.2f, Unit.Centimetre); + page.DefaultTextStyle(t => t.FontSize(10)); + + page.Header().Column(header => + { + header.Item().Text(titel).FontSize(18).Bold().FontColor(Colors.Teal.Darken3); + header.Item().Text($"{ArbetsgivareNamn} (org.nr {ArbetsgivareOrgnr}) — OpenHR") + .FontSize(9).FontColor(Colors.Grey.Darken1); + header.Item().PaddingTop(6).LineHorizontal(1.2f).LineColor(Colors.Teal.Darken3); + }); + + page.Content().PaddingVertical(14).Column(col => + { + col.Spacing(6); + innehall(col); + }); + + page.Footer().AlignCenter().Text(t => + { + t.DefaultTextStyle(s => s.FontSize(8).FontColor(Colors.Grey.Darken1)); + t.Span($"{ArbetsgivareNamn} — genererad {DateTime.Today:yyyy-MM-dd} i OpenHR — sida "); + t.CurrentPageNumber(); + t.Span(" av "); + t.TotalPages(); + }); + }); + }).GeneratePdf(); + } + + private static void Sektionsrubrik(ColumnDescriptor col, string rubrik) + { + col.Item().PaddingTop(8).Text(rubrik).FontSize(11).SemiBold().FontColor(Colors.Teal.Darken3); + col.Item().LineHorizontal(0.6f).LineColor(Colors.Grey.Lighten2); + } + + private static void UppgiftsTabell(ColumnDescriptor col, params (string Rubrik, string Varde)[] rader) + { + col.Item().Table(table => + { + table.ColumnsDefinition(c => + { + c.ConstantColumn(170); + c.RelativeColumn(); + }); + + foreach (var (rubrik, varde) in rader) + { + table.Cell().PaddingVertical(2).Text(rubrik).SemiBold(); + table.Cell().PaddingVertical(2).Text(varde); + } + }); + } + + private static void BeloppTabell(ColumnDescriptor col, params (string Text, decimal Belopp, bool ArSumma)[] rader) + { + col.Item().Table(table => + { + table.ColumnsDefinition(c => + { + c.RelativeColumn(); + c.ConstantColumn(120); + }); + + foreach (var (text, belopp, arSumma) in rader) + { + if (arSumma) + { + table.Cell().BorderTop(0.8f).PaddingVertical(2).Text(text.ToUpperInvariant()).SemiBold(); + table.Cell().BorderTop(0.8f).PaddingVertical(2).AlignRight().Text($"{belopp:N0} kr").SemiBold(); + } + else + { + table.Cell().PaddingVertical(2).Text(text); + table.Cell().PaddingVertical(2).AlignRight().Text($"{belopp:N0} kr"); + } + } + }); } - public byte[] GenerateAnstallningsavtal(AnstallningsavtalData data) + private static void Underskrifter(ColumnDescriptor col, params string[] parter) { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(DblRuler); - sb.AppendLine(" ANSTÄLLNINGSAVTAL"); - sb.AppendLine(" Västra Götalandsregionen — OpenHR"); - sb.AppendLine(DblRuler); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine(" PARTER"); - sb.AppendLine(Ruler); - sb.AppendLine($" Arbetsgivare: {data.Arbetsgivare}"); - sb.AppendLine($" Arbetstagare: {data.Namn} ({data.Personnummer})"); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine(" ANSTÄLLNINGSVILLKOR"); - sb.AppendLine(Ruler); - sb.AppendLine($" Befattning: {data.Befattning}"); - sb.AppendLine($" Tillträdesdag: {data.Tilltrade}"); - sb.AppendLine($" Anställningsform: {data.Anstallningsform}"); - sb.AppendLine($" Sysselsättningsgrad: {data.Grad}%"); - sb.AppendLine($" Månadslön: {data.Lon:N0} kr"); - sb.AppendLine($" Kollektivavtal: {data.Kollektivavtal}"); - sb.AppendLine(); - sb.AppendLine(Ruler); - sb.AppendLine(" UNDERSKRIFTER"); - sb.AppendLine(Ruler); - sb.AppendLine(); - sb.AppendLine(" ___________________________ ___________________________"); - sb.AppendLine(" Arbetsgivare Arbetstagare"); - sb.AppendLine(); - sb.AppendLine($" Datum: {DateTime.Today:yyyy-MM-dd}"); - sb.AppendLine(DblRuler); - sb.AppendLine(); - sb.AppendLine(" PDF-generering kräver QuestPDF-paketet."); - sb.AppendLine(" Installera med: dotnet add package QuestPDF"); - return System.Text.Encoding.UTF8.GetBytes(sb.ToString()); + col.Item().PaddingTop(34).Row(row => + { + foreach (var part in parter) + { + row.RelativeItem().PaddingRight(30).Column(c => + { + c.Item().LineHorizontal(0.8f); + c.Item().PaddingTop(4).Text(part).FontSize(9); + }); + } + }); } } public record LonespecData(string Namn, string Personnummer, string Period, string Utbetalningsdag, decimal Grundlon, decimal OBTillagg, decimal Overtid, decimal Brutto, decimal KommunalSkatt, decimal StatligSkatt, decimal Netto, decimal Arbetsgivaravgift); public record TjanstgoringsintyData(string Namn, string Personnummer, string Arbetsgivare, string StartDatum, string SlutDatum, string Befattning, string Anstallningsform, string Sysselsattningsgrad); -public record AnstallningsavtalData(string Namn, string Personnummer, string Arbetsgivare, string Befattning, string Tilltrade, string Anstallningsform, int Grad, decimal Lon, string Kollektivavtal); diff --git a/src/Infrastructure/LAS/EmploymentEndedLASHandler.cs b/src/Infrastructure/LAS/EmploymentEndedLASHandler.cs index 195973c..3c62cff 100644 --- a/src/Infrastructure/LAS/EmploymentEndedLASHandler.cs +++ b/src/Infrastructure/LAS/EmploymentEndedLASHandler.cs @@ -11,6 +11,12 @@ namespace RegionHR.Infrastructure.LAS; /// Registreras i DI som och löses ut av /// DomainEventDispatcher efter att avslutet sparats. /// +/// KÄND BEGRÄNSNING (§25 LAS): företrädesrätt gäller bara när anställningen upphört +/// på grund av arbetsbrist eller att en tidsbegränsad anställning löpt ut — inte vid +/// t.ex. egen uppsägning. EmploymentEndedEvent bär i dag ingen avslutsorsak, +/// så bedömningen görs för ALLA avslut. När Core exponerar ett orsaksfält ska +/// hanteraren filtrera på det innan auto-kedjan anropas. +/// /// Fel i LAS-bokföringen får inte fälla själva avslutet — undantag fångas och loggas. /// public sealed class EmploymentEndedLASHandler : IDomainEventHandler diff --git a/src/Infrastructure/Payroll/PayrollInputBuilder.cs b/src/Infrastructure/Payroll/PayrollInputBuilder.cs index 2b9f7ed..0773812 100644 --- a/src/Infrastructure/Payroll/PayrollInputBuilder.cs +++ b/src/Infrastructure/Payroll/PayrollInputBuilder.cs @@ -6,14 +6,15 @@ using RegionHR.Payroll.Engine; using RegionHR.Scheduling.Domain; using RegionHR.SharedKernel.Domain; +using RegionHR.Travel.Domain; namespace RegionHR.Infrastructure.Payroll; /// /// Bygger ett riktigt per anställd och period ur databasen. -/// Systemet är experten: underlaget härleds ur schema (OB, övertid, jour, beredskap), -/// frånvaroregister (sjuk, semester, föräldraledighet) och anställningens giltighetsperiod — -/// inte ur handmatade schabloner. +/// Systemet är experten: underlaget härleds ur schema (OB inkl. natt-timmar, övertid, jour, +/// beredskap), frånvaroregister (sjuk, semester, föräldraledighet), attesterade resekrav +/// och anställningens giltighetsperiod — inte ur handmatade schabloner. /// /// Aggregeringen sker i minnet eftersom och /// är beräknade egenskaper som EF inte kan översätta. @@ -54,7 +55,7 @@ public async Task BuildAsync( { ArbetadeDagar = arbetadeDagar, ArbetsdagarIManadens = arbetsdagarIManaden, - Kostnadsstalle = employment.EnhetId.Value.ToString() + Kostnadsstalle = await HamtaKostnadsstalleAsync(db, employment, ct) }; // === Schema: OB, övertid, jour, beredskap === @@ -93,6 +94,29 @@ public async Task BuildAsync( AggregeraFranvaro(leave, firstDay, lastDay, input); + // === Resekrav: attesterade (klara för utbetalning) krav t.o.m. periodens slut === + // Betalas ut som skattefria ersättningar på lönespecifikationen. Kravet ska + // markeras som utbetalt (MarkeraSomUtbetald) när lönekörningen betalas ut — + // annars följer det med igen i nästa körning. + var resekrav = await db.TravelClaims + .Where(t => t.AnstallId == anstallId + && t.Status == TravelClaimStatus.Godkand + && t.ReseDatum <= lastDay) + .ToListAsync(ct); + + foreach (var krav in resekrav) + { + var traktamente = krav.Traktamente ?? Money.Zero; + var milersattning = krav.Milersattning ?? Money.Zero; + // Utläggsdelen härleds ur totalbeloppet så att utläggsraderna inte behöver laddas. + var utlagg = krav.TotalBelopp - traktamente - milersattning; + + input.ReseTraktamente += traktamente; + input.ReseMilersattning += milersattning; + if (utlagg > Money.Zero) + input.ReseUtlagg += utlagg; + } + // === Nettolöneavdrag: löneutmätning (Kronofogden) + fackavgift ur registren === // Systemet är experten: beloppen härleds ur utmätnings-/fackavgiftsregistren per anställd // och period — aldrig en schablon. Löneutmätningen kapas mot förbehållsbeloppet. @@ -213,11 +237,35 @@ private static Money BeraknaLoneutmatning(IReadOnlyList aktiva, M } /// - /// Aggregera OB-timmar per kategori, övertid, jour- och beredskapstimmar ur passen. + /// Enhetens kostnadsställe (kort kod, t.ex. "20032") ur organisationsregistret. + /// Tidigare användes enhetens GUID (36 tecken) vilket sprängde kolumnen + /// payroll_result_lines.kostnadsstalle (varchar(20)) och fick hela lönekörningens + /// SaveChanges att krascha med "An error occurred while saving the entity changes". + /// Saknas enheten i registret lämnas kostnadsstället tomt. + /// + private static async Task HamtaKostnadsstalleAsync( + RegionHRDbContext db, Employment employment, CancellationToken ct) + { + var kostnadsstalle = await db.OrganizationUnits + .Where(o => o.Id == employment.EnhetId) + .Select(o => o.Kostnadsstalle) + .FirstOrDefaultAsync(ct); + + if (string.IsNullOrWhiteSpace(kostnadsstalle)) + return null; + + // Hård vakt mot kolumnens varchar(20) — koden ska normalt vara kort. + return kostnadsstalle.Length <= 20 ? kostnadsstalle : kostnadsstalle[..20]; + } + + /// + /// Aggregera OB-timmar per kategori (inkl. natt-timmar kl. 22–06 för den natthöjda + /// O-satsen), övertid, jour- och beredskapstimmar ur passen. /// private static void AggregeraSchema(IReadOnlyList shifts, PayrollInput input) { var obPerKategori = new Dictionary(); + var obNattPerKategori = new Dictionary(); var overtid = 0m; var kvalificerad = false; var jour = 0m; @@ -241,6 +289,17 @@ private static void AggregeraSchema(IReadOnlyList shifts, Payrol { obPerKategori.TryGetValue(pass.OBKategori, out var befintlig); obPerKategori[pass.OBKategori] = befintlig + timmar; + + // Natt-timmar (kl. 22–06) för natthöjd O-sats (helg/storhelg, AB § 21 anm.) + var (startTid, slutTid) = pass.FaktiskStart is not null && pass.FaktiskSlut is not null + ? (pass.FaktiskStart.Value, pass.FaktiskSlut.Value) + : (pass.PlaneradStart, pass.PlaneradSlut); + var natt = Math.Min(timmar, BeraknaNattTimmar(startTid, slutTid)); + if (natt > 0m) + { + obNattPerKategori.TryGetValue(pass.OBKategori, out var befintligNatt); + obNattPerKategori[pass.OBKategori] = befintligNatt + natt; + } } if (pass.OvertidTimmar is { } ot && ot > 0m) @@ -251,7 +310,12 @@ private static void AggregeraSchema(IReadOnlyList shifts, Payrol } input.OBTimmar = obPerKategori - .Select(kv => new OBInput { Kategori = kv.Key, Timmar = kv.Value }) + .Select(kv => new OBInput + { + Kategori = kv.Key, + Timmar = kv.Value, + NattTimmar = obNattPerKategori.GetValueOrDefault(kv.Key) + }) .OrderBy(o => o.Kategori) .ToList(); input.OvertidTimmar = overtid; @@ -260,6 +324,25 @@ private static void AggregeraSchema(IReadOnlyList shifts, Payrol input.BeredskapsTimmar = beredskap; } + /// + /// Antal timmar av ett pass som ligger inom nattfönstret kl. 22.00–06.00. + /// Hanterar pass som korsar midnatt. Rasten antas ligga utanför natten + /// (försiktig överskattning kapas mot passets totala timmar av anroparen). + /// + private static decimal BeraknaNattTimmar(TimeOnly start, TimeOnly slut) + { + var s = (decimal)start.ToTimeSpan().TotalHours; + var e = (decimal)slut.ToTimeSpan().TotalHours; + if (e <= s) e += 24m; // Korsar midnatt + + // Nattfönstren på en 48-timmarsaxel: 00–06, 22–30 (= 22–24 + 00–06 nästa dygn) + // och 46–54 (= 22–24 andra dygnet, för extremt långa pass). + var natt = 0m; + foreach (var (ws, we) in new[] { (0m, 6m), (22m, 30m), (46m, 54m) }) + natt += Math.Max(0m, Math.Min(e, we) - Math.Max(s, ws)); + return natt; + } + /// /// Aggregera frånvarodagar (arbetsdagar inom perioden) per frånvarotyp. /// @@ -292,7 +375,10 @@ private static void AggregeraFranvaro( } // Sjuklön betalas endast dag 2–14; övriga sjukdagar ligger hos Försäkringskassan. + // Löneavdraget görs dock för ALLA sjukdagar — dag 15+ redovisas separat så att + // motorn kan dra full daglön utan att generera sjuklön. input.SjukdagarMedLon = Math.Min(sjuk, MaxSjuklonedagar); + input.SjukdagarUtanLon = Math.Max(0, sjuk - MaxSjuklonedagar); input.SemesterdagarUttagna = semester; input.ForaldraledigaDagar = foraldra; } diff --git a/src/Infrastructure/Persistence/AuditInterceptor.cs b/src/Infrastructure/Persistence/AuditInterceptor.cs index b16f649..c1da368 100644 --- a/src/Infrastructure/Persistence/AuditInterceptor.cs +++ b/src/Infrastructure/Persistence/AuditInterceptor.cs @@ -7,12 +7,34 @@ namespace RegionHR.Infrastructure.Persistence; +/// +/// Abstraktion över "vem är inloggad just nu?" så att infrastrukturlagret kan +/// stämpla granskningsposter med den faktiska användaren utan att känna till +/// webblagret. Interfacet ligger i Infrastructure (inte Web) eftersom +/// beroenderiktningen är Web → Infrastructure; den HttpContext-baserade +/// implementationen finns i src/Web/Services/HttpContextCurrentUser.cs. +/// +public interface ICurrentUser +{ + /// Stabil identifierare för den inloggade (t.ex. employee_id), eller null om okänd. + string? Id { get; } + + /// Visningsnamn för den inloggade, eller null om okänd. + string? Namn { get; } +} + /// /// EF Core SaveChangesInterceptor that automatically creates AuditEntry records /// when entities are created, updated, or deleted. +/// +/// Granskningsposter stämplas med den inloggade användaren via +/// . När ingen användare kan härledas (bakgrundsjobb, +/// seeding, migrationer) faller stämpeln tillbaka på "system". /// public sealed class AuditInterceptor : SaveChangesInterceptor { + private const string SystemUser = "system"; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false, @@ -20,6 +42,18 @@ public sealed class AuditInterceptor : SaveChangesInterceptor DictionaryKeyPolicy = JsonNamingPolicy.CamelCase }; + private readonly ICurrentUser? _currentUser; + + /// + /// är avsiktligt valfri: bakgrundstjänster och + /// tester utan användarkontext kan skapa interceptorn utan argument och får + /// då "system"-stämpling som tidigare. + /// + public AuditInterceptor(ICurrentUser? currentUser = null) + { + _currentUser = currentUser; + } + public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, @@ -45,8 +79,15 @@ public override InterceptionResult SavingChanges( return base.SavingChanges(eventData, result); } - private static void CreateAuditEntries(RegionHRDbContext dbContext) + private void CreateAuditEntries(RegionHRDbContext dbContext) { + // Härled användarstämpel EN gång per SaveChanges. Tomt/whitespace + // behandlas som okänt → "system" (spårbarheten får aldrig bli ""). + var currentId = _currentUser?.Id; + var currentNamn = _currentUser?.Namn; + var userId = string.IsNullOrWhiteSpace(currentId) ? SystemUser : currentId; + var userName = string.IsNullOrWhiteSpace(currentNamn) ? SystemUser : currentNamn; + var entries = dbContext.ChangeTracker .Entries() .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) @@ -84,8 +125,8 @@ private static void CreateAuditEntries(RegionHRDbContext dbContext) action: action, oldValues: oldValues, newValues: newValues, - userId: "system", - userName: "system", + userId: userId, + userName: userName, ipAddress: null); dbContext.AuditEntries.Add(auditEntry); diff --git a/src/Infrastructure/Persistence/Configurations/CoreHR/EmploymentConfiguration.cs b/src/Infrastructure/Persistence/Configurations/CoreHR/EmploymentConfiguration.cs index df6ab6e..cc93fea 100644 --- a/src/Infrastructure/Persistence/Configurations/CoreHR/EmploymentConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/CoreHR/EmploymentConfiguration.cs @@ -62,6 +62,15 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.CreatedBy).HasColumnName("created_by").HasMaxLength(100); builder.Property(e => e.UpdatedBy).HasColumnName("updated_by").HasMaxLength(100); + // Optimistisk låsning: PostgreSQL:s systemkolumn xmin som samtidighetstoken + // (shadow property — Npgsql utelämnar systemkolumnen ur CREATE TABLE; + // InMemory-providern behandlar den som vanlig token → tester opåverkade). + // Samtidiga skrivningar mot samma anställning ger DbUpdateConcurrencyException + // i stället för tyst "last write wins". + builder.Property("xmin") + .IsRowVersion() + .HasColumnName("xmin"); + builder.Ignore(e => e.DomainEvents); builder.Ignore(e => e.ArTillsvidareanstallning); builder.Ignore(e => e.ArTidsbegransad); diff --git a/src/Infrastructure/Persistence/Configurations/LAS/LASConfiguration.cs b/src/Infrastructure/Persistence/Configurations/LAS/LASConfiguration.cs index 384d39d..1e3c23e 100644 --- a/src/Infrastructure/Persistence/Configurations/LAS/LASConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/LAS/LASConfiguration.cs @@ -18,6 +18,9 @@ public void Configure(EntityTypeBuilder builder) .HasColumnName("anstalld_id"); builder.Property(e => e.Anstallningsform).HasConversion().HasColumnName("anstallningsform").HasMaxLength(30); builder.Property(e => e.AckumuleradeDagar).HasColumnName("ackumulerade_dagar"); + // Per-form-räknare (SAVA/vikariat räknas mot varsin LAS-gräns) — våg: LAS-buggfix + builder.Property(e => e.AckumuleradeSavaDagar).HasColumnName("ackumulerade_sava_dagar"); + builder.Property(e => e.AckumuleradeVikariatDagar).HasColumnName("ackumulerade_vikariat_dagar"); builder.Property(e => e.ReferensfonsterStart).HasColumnName("referensfonster_start"); builder.Property(e => e.ReferensfonsterSlut).HasColumnName("referensfonster_slut"); builder.Property(e => e.Status).HasConversion().HasColumnName("status").HasMaxLength(30); @@ -49,6 +52,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.SlutDatum).HasColumnName("slut_datum"); builder.Property(e => e.AntalDagar).HasColumnName("antal_dagar"); builder.Property(e => e.AnstallningsId).HasColumnName("anstallnings_id").HasMaxLength(100); + // Periodens anställningsform avgör vilken LAS-gräns dagarna räknas mot — våg: LAS-buggfix + builder.Property(e => e.Anstallningsform).HasConversion().HasColumnName("anstallningsform").HasMaxLength(30); } } diff --git a/src/Infrastructure/Persistence/Configurations/Payroll/PayrollRunConfiguration.cs b/src/Infrastructure/Persistence/Configurations/Payroll/PayrollRunConfiguration.cs index 196bf06..57c26d7 100644 --- a/src/Infrastructure/Persistence/Configurations/Payroll/PayrollRunConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/Payroll/PayrollRunConfiguration.cs @@ -36,6 +36,15 @@ public void Configure(EntityTypeBuilder builder) builder.HasMany(e => e.Resultat).WithOne().HasForeignKey(r => r.KorningsId); + // Optimistisk låsning: PostgreSQL:s systemkolumn xmin som samtidighetstoken + // (shadow property — Npgsql utelämnar systemkolumnen ur CREATE TABLE; + // InMemory-providern behandlar den som vanlig token → tester opåverkade). + // Samtidiga skrivningar mot samma lönekörning ger DbUpdateConcurrencyException + // i stället för tyst "last write wins". + builder.Property("xmin") + .IsRowVersion() + .HasColumnName("xmin"); + builder.Ignore(e => e.DomainEvents); builder.Ignore(e => e.Period); builder.Ignore(e => e.UpdatedAt); diff --git a/src/Infrastructure/Persistence/SeedData.cs b/src/Infrastructure/Persistence/SeedData.cs index dc8e864..dace05b 100644 --- a/src/Infrastructure/Persistence/SeedData.cs +++ b/src/Infrastructure/Persistence/SeedData.cs @@ -773,10 +773,12 @@ public static async Task SeedAsync(RegionHRDbContext db) runMars.Id, emp.Id, empl.Id, 2026, 3, lon, 100m, RegionHR.SharedKernel.Domain.CollectiveAgreementType.AB); var brutto = lon.Amount; - // Anna Svensson (i=0): 3 nattpass OB + // Anna Svensson (i=0): 3 nattpass à 8 h vardagsnatt-OB if (i == 0) { - result.OBTillagg = Money.SEK(3 * 113m); // natt-OB 113 kr/h * 3 + // AB § 21 O-tilläggstid C (vardagsnatt), sats fr.o.m. 2025-04-01 = 56,70 kr/h + // (mars 2026 ligger före uppräkningen 2026-04-01). + result.OBTillagg = Money.SEK(3 * 8 * 56.70m); brutto += result.OBTillagg.Amount; } // Erik Johansson (i=1): övertid @@ -1467,21 +1469,25 @@ 2. Sjukanmäl dig i OpenHR under *Min sida → Sjukanmälan* "Vad är OB-tillägg?", @"## OB-tillägg (Obekväm arbetstid) -OB-tillägg är extra ersättning för arbete på obekväma tider. Enligt **kollektivavtal AB 2025** gäller: +OB-tillägg (O-tillägg) är extra ersättning för arbete på obekväm arbetstid enligt **AB § 21** (Allmänna bestämmelser). Satserna räknas upp per avtalsår och gäller fr.o.m. 1 april respektive år. -### Belopp per timme -| Tid | Belopp | -|-----|--------| -| Vardag kväll (19–22) | 126,50 kr/h | -| Vardag natt (22–06) | 152,00 kr/h | -| Helg (fre 19 – sön 22) | 89,00 kr/h | -| Storhelg (julafton, nyår etc) | 195,00 kr/h | +### Belopp per timme (AB 25 § 21) +| O-tilläggstid | Fr.o.m. 2025-04-01 | Fr.o.m. 2026-04-01 | +|-----|--------|--------| +| Vardag kväll (19–22, fredag från 17) | 25,60 kr/h | 26,40 kr/h | +| Vardag natt (22–06) | 56,70 kr/h | 58,40 kr/h | +| Helg (lördag, söndag, helgdag) | 66,10 kr/h | 68,10 kr/h | +| Helg, natt 22–06 (förhöjd sats) | 76,00 kr/h | 78,30 kr/h | +| Storhelg | 126,90 kr/h | 130,70 kr/h | +| Storhelg, natt 22–06 (förhöjd sats) | 152,30 kr/h | 156,90 kr/h | + +För helg och storhelg gäller alltså en förhöjd nattsats kl. 22–06 (natt mot lör-/sön-/helgdag). ### Beräkning OB beräknas automatiskt utifrån ditt schema i OpenHR. Du ser OB-tillägget specificerat på din lönespecifikation. ### Storhelger -Med storhelger avses bl.a. julafton, juldagen, annandag jul, nyårsafton, nyårsdagen, påskafton, påskdagen, annandag påsk, Kristi himmelsfärdsdag och midsommarafton.", +Med storhelg avses påskhelgen (påskafton–annandag påsk), midsommarhelgen (midsommarafton–midsommardagen), julhelgen (julafton–annandag jul) samt nyårshelgen (nyårsafton–nyårsdagen). Kristi himmelsfärdsdag är en vanlig helgdag och ger helg-OB, inte storhelgs-OB.", catSchema.Id, ["OB", "obekväm", "tillägg", "kväll", "natt", "helg", "storhelg"]); art3.Publicera(); diff --git a/src/Infrastructure/RegionHR.Infrastructure.csproj b/src/Infrastructure/RegionHR.Infrastructure.csproj index 56050ab..2625ecf 100644 --- a/src/Infrastructure/RegionHR.Infrastructure.csproj +++ b/src/Infrastructure/RegionHR.Infrastructure.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Infrastructure/Reporting/BiDwExportBuilder.cs b/src/Infrastructure/Reporting/BiDwExportBuilder.cs index 1f22820..3a61b41 100644 --- a/src/Infrastructure/Reporting/BiDwExportBuilder.cs +++ b/src/Infrastructure/Reporting/BiDwExportBuilder.cs @@ -128,9 +128,14 @@ public static BiStjarnschema Bygg( } // ── Fakta: frånvaro ─────────────────────────────────────────────────── + // Endast godkänd (eller inskickad, ännu ej behandlad) frånvaro är faktagrundande — + // Utkast/Avslagen/Återkallad ska inte in i DW:t. var faktaFranvaro = new List(); foreach (var lr in leaveRequests) { + if (lr.Status is not (LeaveRequestStatus.Godkand or LeaveRequestStatus.Inskickad)) + continue; + var tid = TidId(lr.FranDatum.Year, lr.FranDatum.Month); tidIds.Add(tid); diff --git a/src/Infrastructure/Services/KPICalculationService.cs b/src/Infrastructure/Services/KPICalculationService.cs index 2c67c22..0589458 100644 --- a/src/Infrastructure/Services/KPICalculationService.cs +++ b/src/Infrastructure/Services/KPICalculationService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using RegionHR.Analytics.Domain; +using RegionHR.Core.Domain; using RegionHR.Infrastructure.Persistence; using RegionHR.Leave.Domain; using RegionHR.Positions.Domain; @@ -80,22 +81,33 @@ internal async Task CalculateKPIValueAsync(string kpiNamn, string perio } /// - /// Headcount: antal aktiva anställningar (öppen slutdatum eller slutdatum i framtiden). + /// Anställningar som är aktiva på angivet datum: påbörjade (Start <= datum) och + /// med öppet slutdatum eller slutdatum på/efter datumet. Framtida (ej påbörjade) + /// anställningar räknas inte, i linje med BeslutsstodKpiService/DateRange.IsActiveOn. + /// + private IQueryable AktivaAnstallningar(DateOnly datum) => + _db.Employments.Where(e => + e.Giltighetsperiod.Start <= datum && + (e.Giltighetsperiod.End == null || e.Giltighetsperiod.End >= datum)); + + /// + /// Headcount: antal individer (distinkta anställda) med minst en aktiv anställning. + /// En person med flera parallella anställningar räknas en gång. /// private async Task CalculateHeadcountAsync(DateOnly today, CancellationToken ct) { - return await _db.Employments - .CountAsync(e => e.Giltighetsperiod.End == null || e.Giltighetsperiod.End > today, ct); + return await AktivaAnstallningar(today) + .Select(e => e.AnstallId) + .Distinct() + .CountAsync(ct); } /// - /// FTE (heltidsekvivalenter): summa sysselsättningsgrad / 100. + /// FTE (heltidsekvivalenter): summa sysselsättningsgrad / 100 för aktiva anställningar. /// private async Task CalculateFTEAsync(DateOnly today, CancellationToken ct) { - var activeEmployments = await _db.Employments - .Where(e => e.Giltighetsperiod.End == null || e.Giltighetsperiod.End > today) - .ToListAsync(ct); + var activeEmployments = await AktivaAnstallningar(today).ToListAsync(ct); if (activeEmployments.Count == 0) return 0m; @@ -131,17 +143,17 @@ private async Task CalculateTurnoverAsync(DateOnly today, CancellationT e.Giltighetsperiod.End >= twelveMonthsAgo && e.Giltighetsperiod.End <= today, ct); - var currentHeadcount = await _db.Employments - .CountAsync(e => e.Giltighetsperiod.End == null || e.Giltighetsperiod.End > today, ct); + var currentHeadcount = await CalculateHeadcountAsync(today, ct); if (currentHeadcount == 0) return 0m; - return (decimal)terminatedCount / currentHeadcount * 100m; + return terminatedCount / currentHeadcount * 100m; } /// /// Sjukfrånvaro %: sjukdagar under perioden / (headcount * arbetsdagar i perioden) * 100. - /// Periodsformat: "YYYY-QN" (t.ex. "2026-Q1"). + /// Endast godkänd (eller inskickad, ännu ej behandlad) frånvaro räknas — + /// Utkast/Avslagen/Återkallad exkluderas. Periodsformat: "YYYY-QN" (t.ex. "2026-Q1"). /// private async Task CalculateSickLeavePercentAsync(string period, DateOnly today, CancellationToken ct) { @@ -149,12 +161,12 @@ private async Task CalculateSickLeavePercentAsync(string period, DateOn var totalSickDays = await _db.LeaveRequests .Where(r => r.Typ == LeaveType.Sjukfranvaro && + (r.Status == LeaveRequestStatus.Godkand || r.Status == LeaveRequestStatus.Inskickad) && r.FranDatum >= periodStart && r.FranDatum <= periodEnd) .SumAsync(r => r.AntalDagar, ct); - var headcount = await _db.Employments - .CountAsync(e => e.Giltighetsperiod.End == null || e.Giltighetsperiod.End > today, ct); + var headcount = await CalculateHeadcountAsync(today, ct); if (headcount == 0) return 0m; diff --git a/src/Modules/Agreements/Domain/ABOTillaggSatser.cs b/src/Modules/Agreements/Domain/ABOTillaggSatser.cs index a5158bf..12344f6 100644 --- a/src/Modules/Agreements/Domain/ABOTillaggSatser.cs +++ b/src/Modules/Agreements/Domain/ABOTillaggSatser.cs @@ -34,10 +34,12 @@ public static class ABOTillaggSatser // Ordnad fallande på GiltigFran så att första posten med GiltigFran <= datum gäller. private static readonly ABOTillaggSats[] Tabell = [ - // AB § 21 mom. 1 — gäller fr.o.m. 2026-04-01 + // AB § 21 mom. 1 — gäller fr.o.m. 2026-04-01. + // HelgBNatt enligt AB 25 § 21 anm. 1: O-tillägg B höjs med 30 % kl. 22.00–06.00 + // natt mot lör/sön/helgdag → 78,30 × 1,30 = 101,80 kr/tim. new(new DateOnly(2026, 4, 1), StorhelgA: 130.70m, StorhelgANatt: 156.90m, - HelgB: 68.10m, HelgBNatt: 78.30m, + HelgB: 68.10m, HelgBNatt: 101.80m, VardagNattC: 58.40m, VardagKvallD: 26.40m), // AB § 21 mom. 1 — gäller fr.o.m. 2025-04-01 diff --git a/src/Modules/IntegrationHub/Adapters/SCB/SCBLonestatistikGenerator.cs b/src/Modules/IntegrationHub/Adapters/SCB/SCBLonestatistikGenerator.cs index 9797b25..a03e26a 100644 --- a/src/Modules/IntegrationHub/Adapters/SCB/SCBLonestatistikGenerator.cs +++ b/src/Modules/IntegrationHub/Adapters/SCB/SCBLonestatistikGenerator.cs @@ -185,12 +185,21 @@ internal static decimal Percentil(IReadOnlyList sorterade, decimal p) return Math.Round(varde, 0, MidpointRounding.AwayFromZero); } - /// Normaliserar könsangivelse till "K" (kvinna) eller "M" (man). + /// + /// Normaliserar könsangivelse till "K" (kvinna) eller "M" (man). + /// Tom eller okänd angivelse ger "Okänt" och bildar en egen grupp i stället för + /// att snedvrida statistiken genom att felaktigt räknas som man. Individer med + /// okänt kön ingår inte i kvinnors löneandel (). + /// internal static string NormaliseraKon(string? kon) { - if (string.IsNullOrWhiteSpace(kon)) return "M"; - var k = kon.Trim(); - return k[0] is 'K' or 'k' or '2' ? "K" : "M"; + if (string.IsNullOrWhiteSpace(kon)) return "Okänt"; + return kon.Trim()[0] switch + { + 'K' or 'k' or '2' => "K", + 'M' or 'm' or '1' => "M", + _ => "Okänt" + }; } private static string Rensa(string s) => diff --git a/src/Modules/IntegrationHub/Adapters/SKR/SKRNovemberstatistikGenerator.cs b/src/Modules/IntegrationHub/Adapters/SKR/SKRNovemberstatistikGenerator.cs index 6e1c9c4..09994a7 100644 --- a/src/Modules/IntegrationHub/Adapters/SKR/SKRNovemberstatistikGenerator.cs +++ b/src/Modules/IntegrationHub/Adapters/SKR/SKRNovemberstatistikGenerator.cs @@ -139,12 +139,20 @@ private static string Rensa(string s) => /// Könsnormalisering för SKR-statistiken. internal static class SKRKon { - /// Normaliserar könsangivelse till "K" (kvinna) eller "M" (man). + /// + /// Normaliserar könsangivelse till "K" (kvinna) eller "M" (man). + /// Tom eller okänd angivelse ger "Okänt" och bildar en egen grupp i stället för + /// att snedvrida statistiken genom att felaktigt räknas som man. + /// public static string Normalisera(string? kon) { - if (string.IsNullOrWhiteSpace(kon)) return "M"; - var k = kon.Trim(); - return k[0] is 'K' or 'k' or '2' ? "K" : "M"; + if (string.IsNullOrWhiteSpace(kon)) return "Okänt"; + return kon.Trim()[0] switch + { + 'K' or 'k' or '2' => "K", + 'M' or 'm' or '1' => "M", + _ => "Okänt" + }; } } diff --git a/src/Modules/LAS/Domain/LASAccumulation.cs b/src/Modules/LAS/Domain/LASAccumulation.cs index bd4a1c5..737f45c 100644 --- a/src/Modules/LAS/Domain/LASAccumulation.cs +++ b/src/Modules/LAS/Domain/LASAccumulation.cs @@ -7,8 +7,10 @@ namespace RegionHR.LAS.Domain; /// LAS-ackumulering per anställd. /// /// Regler: -/// - SAVA: max 12 månader i en 5-årsperiod (sedan 2022-10-01), konvertering vid överskridande -/// - Vikariat: max 2 år i en 5-årsperiod +/// - SAVA: mer än 12 månader i en 5-årsperiod (sedan 2022-10-01) → konvertering +/// - Vikariat: mer än 2 år i en 5-årsperiod → konvertering +/// - SAVA-tid och vikariatstid ackumuleras SEPARAT mot sina respektive gränser — +/// varje period bär sin egen anställningsform och räknas aldrig mot fel gräns /// - 3-i-månad-regeln: 3+ SAVA-avtal i samma kalendermånad → mellanliggande perioder räknas /// - Företrädesrätt: 9 månader efter anställningens slut /// @@ -35,8 +37,22 @@ public sealed class LASAccumulation : AggregateRoot public const int SAVA_ALARM_12_MANADER = 365; public EmployeeId AnstallId { get; private set; } + + /// + /// Ursprunglig/huvudsaklig anställningsform (den form ackumuleringen skapades med). + /// Själva ackumuleringen räknas per periodens form — se + /// och . + /// public EmploymentType Anstallningsform { get; private set; } + + /// Totalt ackumulerade dagar (SAVA + vikariat) — används för turordning. public int AckumuleradeDagar { get; private set; } + + /// SAVA-dagar inom referensfönstret, räknas mot gränsen 365 dagar. + public int AckumuleradeSavaDagar { get; private set; } + + /// Vikariatsdagar inom referensfönstret, räknas mot gränsen 730 dagar. + public int AckumuleradeVikariatDagar { get; private set; } public DateOnly ReferensfonsterStart { get; private set; } public DateOnly ReferensfonsterSlut { get; private set; } public LASStatus Status { get; private set; } @@ -70,15 +86,24 @@ public static LASAccumulation Skapa(EmployeeId anstallId, EmploymentType anstall }; } - /// Registrera en anställningsperiod för LAS-ackumulering. - public void LaggTillPeriod(DateOnly startDatum, DateOnly slutDatum, string? anstallningsId = null) + /// + /// Registrera en anställningsperiod för LAS-ackumulering. + /// Periodens form styr vilken gräns den räknas mot (SAVA 365 / vikariat 730). + /// Utelämnas används ackumuleringens ursprungsform. + /// + public void LaggTillPeriod(DateOnly startDatum, DateOnly slutDatum, string? anstallningsId = null, EmploymentType? form = null) { + var periodForm = form ?? Anstallningsform; + if (periodForm is not (EmploymentType.SAVA or EmploymentType.Vikariat)) + throw new ArgumentException("LAS-perioder kan bara registreras för SAVA och vikariat"); + var period = new LASPeriod { StartDatum = startDatum, SlutDatum = slutDatum, AntalDagar = slutDatum.DayNumber - startDatum.DayNumber + 1, - AnstallningsId = anstallningsId + AnstallningsId = anstallningsId, + Anstallningsform = periodForm }; _perioder.Add(period); @@ -92,32 +117,41 @@ public void Omberakna(DateOnly referensDatum) ReferensfonsterStart = referensDatum.AddYears(-REFERENSFONSTER_AR); ReferensfonsterSlut = referensDatum; - AckumuleradeDagar = _perioder - .Where(p => p.SlutDatum >= ReferensfonsterStart && p.StartDatum <= ReferensfonsterSlut) - .Sum(p => - { - var effectiveStart = p.StartDatum < ReferensfonsterStart ? ReferensfonsterStart : p.StartDatum; - var effectiveEnd = p.SlutDatum > ReferensfonsterSlut ? ReferensfonsterSlut : p.SlutDatum; - return effectiveEnd.DayNumber - effectiveStart.DayNumber + 1; - }); + // SAVA- och vikariatstid ackumuleras SEPARAT mot sina respektive gränser + // (365 resp. 730 dagar i 5-årsfönstret) — de blandas aldrig mot fel gräns. + AckumuleradeSavaDagar = DagarInomFonster(EmploymentType.SAVA, ReferensfonsterStart, ReferensfonsterSlut); + AckumuleradeVikariatDagar = DagarInomFonster(EmploymentType.Vikariat, ReferensfonsterStart, ReferensfonsterSlut); - // Kontrollera 3-i-månad-regeln + // Kontrollera 3-i-månad-regeln (gäller SAVA-avtal) KontrolleraTreIManad(); + AckumuleradeDagar = AckumuleradeSavaDagar + AckumuleradeVikariatDagar; + // Uppdatera status UppdateraStatus(); } + /// Summera dagar för en anställningsform inom ett datumfönster (klipper mot fönstrets kanter). + private int DagarInomFonster(EmploymentType form, DateOnly fonsterStart, DateOnly fonsterSlut) => + _perioder + .Where(p => p.Anstallningsform == form && p.SlutDatum >= fonsterStart && p.StartDatum <= fonsterSlut) + .Sum(p => + { + var effectiveStart = p.StartDatum < fonsterStart ? fonsterStart : p.StartDatum; + var effectiveEnd = p.SlutDatum > fonsterSlut ? fonsterSlut : p.SlutDatum; + return effectiveEnd.DayNumber - effectiveStart.DayNumber + 1; + }); + private void KontrolleraTreIManad() { - // Gruppera perioder per kalendermånad + // Gruppera SAVA-perioder per kalendermånad (regeln gäller SAVA-avtal) var perioderPerManad = _perioder - .Where(p => p.SlutDatum >= ReferensfonsterStart) + .Where(p => p.Anstallningsform == EmploymentType.SAVA && p.SlutDatum >= ReferensfonsterStart) .GroupBy(p => new { p.StartDatum.Year, p.StartDatum.Month }); foreach (var group in perioderPerManad) { - if (group.Count() >= 3 && Anstallningsform == EmploymentType.SAVA) + if (group.Count() >= 3) { // Mellanliggande perioder ska räknas var sortedPeriods = group.OrderBy(p => p.StartDatum).ToList(); @@ -126,10 +160,12 @@ private void KontrolleraTreIManad() var gap = sortedPeriods[i + 1].StartDatum.DayNumber - sortedPeriods[i].SlutDatum.DayNumber - 1; if (gap > 0) { - AckumuleradeDagar += gap; - _handelser.Add(LASEvent.Skapa( - LASEventTyp.TreIManadRegelTillampad, - $"3-i-månadsregeln: {gap} mellanliggande dagar räknas ({sortedPeriods[i].SlutDatum} - {sortedPeriods[i + 1].StartDatum})")); + AckumuleradeSavaDagar += gap; + var beskrivning = + $"3-i-månadsregeln: {gap} mellanliggande dagar räknas ({sortedPeriods[i].SlutDatum} - {sortedPeriods[i + 1].StartDatum})"; + // Omberakna körs återkommande (dagligt jobb) — logga inte samma händelse igen. + if (!_handelser.Any(h => h.Typ == LASEventTyp.TreIManadRegelTillampad && h.Beskrivning == beskrivning)) + _handelser.Add(LASEvent.Skapa(LASEventTyp.TreIManadRegelTillampad, beskrivning)); } } } @@ -138,32 +174,45 @@ private void KontrolleraTreIManad() private void UppdateraStatus() { - var maxDagar = Anstallningsform == EmploymentType.SAVA ? SAVA_MAX_DAGAR_5AR : VIKARIAT_MAX_DAGAR_5AR; var previousStatus = Status; - if (AckumuleradeDagar >= maxDagar) + // §5a LAS säger "mer än" — konvertering sker först när gränsen PASSERAS (strikt >). + var savaOverGrans = AckumuleradeSavaDagar > SAVA_MAX_DAGAR_5AR; + var vikariatOverGrans = AckumuleradeVikariatDagar > VIKARIAT_MAX_DAGAR_5AR; + + if (savaOverGrans || vikariatOverGrans) { Status = LASStatus.KonverteradTillTillsvidare; KonverteringsDatum ??= DateOnly.FromDateTime(DateTime.Today); if (previousStatus != LASStatus.KonverteradTillTillsvidare) { + var (form, dagar, grans) = savaOverGrans + ? (EmploymentType.SAVA, AckumuleradeSavaDagar, SAVA_MAX_DAGAR_5AR) + : (EmploymentType.Vikariat, AckumuleradeVikariatDagar, VIKARIAT_MAX_DAGAR_5AR); _handelser.Add(LASEvent.Skapa( LASEventTyp.Konvertering, - $"Konverterad till tillsvidareanställning efter {AckumuleradeDagar} dagar")); - RaiseDomainEvent(new LASConversionTriggeredEvent(AnstallId, Anstallningsform, AckumuleradeDagar)); + $"Konverterad till tillsvidareanställning efter {dagar} dagar ({form}, gräns {grans})")); + RaiseDomainEvent(new LASConversionTriggeredEvent(AnstallId, form, dagar)); } } - else if (Anstallningsform == EmploymentType.SAVA && AckumuleradeDagar >= SAVA_ALARM_11_MANADER) + else if (previousStatus == LASStatus.KonverteradTillTillsvidare) + { + // Konvertering är enkelriktad: att referensfönstret glider vidare vid den + // återkommande omberäkningen (dagligt jobb) ska inte "avkonvertera", och en + // manuell konvertering ska inte wipas. Endast HR-korrigering av perioder + // återställer — se AterstallKonverteringOmUnderGrans(). + } + else if (AckumuleradeSavaDagar >= SAVA_ALARM_11_MANADER) { Status = LASStatus.KritiskNara; if (previousStatus != LASStatus.KritiskNara) - _handelser.Add(LASEvent.Skapa(LASEventTyp.Alarm, $"KRITISKT: {AckumuleradeDagar} dagar av {maxDagar}")); + _handelser.Add(LASEvent.Skapa(LASEventTyp.Alarm, $"KRITISKT: {AckumuleradeSavaDagar} dagar av {SAVA_MAX_DAGAR_5AR}")); } - else if (Anstallningsform == EmploymentType.SAVA && AckumuleradeDagar >= SAVA_ALARM_10_MANADER) + else if (AckumuleradeSavaDagar >= SAVA_ALARM_10_MANADER) { Status = LASStatus.NaraGrans; if (previousStatus != LASStatus.NaraGrans) - _handelser.Add(LASEvent.Skapa(LASEventTyp.Alarm, $"Varning: {AckumuleradeDagar} dagar av {maxDagar}")); + _handelser.Add(LASEvent.Skapa(LASEventTyp.Alarm, $"Varning: {AckumuleradeSavaDagar} dagar av {SAVA_MAX_DAGAR_5AR}")); } else { @@ -173,35 +222,33 @@ private void UppdateraStatus() /// /// Sätt företrädesrätt efter anställningens slut. - /// Per 25§ LAS: - /// - SAVA: företrädesrätt efter minst 9 månader (~274 dagar) i en 3-årsperiod - /// - Vikariat/tillsvidare: företrädesrätt efter minst 12 månader (~365 dagar) i en 3-årsperiod + /// Per 25§ LAS ("mer än" — kravet ska PASSERAS, strikt >): + /// - SAVA: företrädesrätt efter mer än 9 månader (274 dagar) SAVA-tid i en 3-årsperiod + /// - Vikariat: företrädesrätt efter mer än 12 månader (365 dagar) vikariatstid i en 3-årsperiod + /// Kravet bedöms per anställningsform — SAVA-tid och vikariatstid blandas inte. /// Företrädesrätten gäller i 9 månader från anställningens slut. /// public void SattForetradesratt(DateOnly anstallningSlut) { - // Beräkna dagar i 3-årsperioden + // Beräkna dagar i 3-årsperioden — per anställningsform, mot respektive lagkrav var treArsStart = anstallningSlut.AddYears(-FORETRADESRATT_REFERENSFONSTER_AR); - var dagarITreArsfonster = _perioder - .Where(p => p.SlutDatum >= treArsStart && p.StartDatum <= anstallningSlut) - .Sum(p => - { - var effectiveStart = p.StartDatum < treArsStart ? treArsStart : p.StartDatum; - var effectiveEnd = p.SlutDatum > anstallningSlut ? anstallningSlut : p.SlutDatum; - return effectiveEnd.DayNumber - effectiveStart.DayNumber + 1; - }); + var savaDagar = DagarInomFonster(EmploymentType.SAVA, treArsStart, anstallningSlut); + var vikariatDagar = DagarInomFonster(EmploymentType.Vikariat, treArsStart, anstallningSlut); - var kravDagar = Anstallningsform == EmploymentType.SAVA - ? FORETRADESRATT_SAVA_DAGAR - : FORETRADESRATT_VIKARIAT_DAGAR; + var (uppfylld, dagar, kravDagar) = + savaDagar > FORETRADESRATT_SAVA_DAGAR + ? (true, savaDagar, FORETRADESRATT_SAVA_DAGAR) + : vikariatDagar > FORETRADESRATT_VIKARIAT_DAGAR + ? (true, vikariatDagar, FORETRADESRATT_VIKARIAT_DAGAR) + : (false, 0, 0); - if (dagarITreArsfonster >= kravDagar) + if (uppfylld) { HarForetradesratt = true; ForetradesrattUtgar = anstallningSlut.AddMonths(FORETRADESRATT_MANADER); _handelser.Add(LASEvent.Skapa( LASEventTyp.ForetradesrattBeviljad, - $"Företrädesrätt till {ForetradesrattUtgar.Value} ({dagarITreArsfonster} dagar i 3-årsperiod, krav: {kravDagar} dagar)")); + $"Företrädesrätt till {ForetradesrattUtgar.Value} ({dagar} dagar i 3-årsperiod, krav: mer än {kravDagar} dagar)")); } } @@ -277,7 +324,19 @@ public bool KonverteraTillTillsvidare(DateOnly konverteringsDatum, string beslut private void AterstallKonverteringOmUnderGrans() { if (Status != LASStatus.KonverteradTillTillsvidare) + { + KonverteringsDatum = null; + return; + } + + // HR-korrigering: om dagarna efter korrigeringen inte längre passerar någon + // gräns ska konverteringen återställas och statusnivån härledas om. + if (AckumuleradeSavaDagar <= SAVA_MAX_DAGAR_5AR && AckumuleradeVikariatDagar <= VIKARIAT_MAX_DAGAR_5AR) + { + Status = LASStatus.UnderGrans; KonverteringsDatum = null; + UppdateraStatus(); + } } /// Generera turordningslista per driftsenhet. @@ -290,6 +349,9 @@ public sealed class LASPeriod public DateOnly SlutDatum { get; set; } public int AntalDagar { get; set; } public string? AnstallningsId { get; set; } + + /// Periodens anställningsform — avgör vilken LAS-gräns dagarna räknas mot. + public EmploymentType Anstallningsform { get; set; } } public sealed class LASEvent diff --git a/src/Modules/LAS/Services/LASService.cs b/src/Modules/LAS/Services/LASService.cs index 2a707a1..f517990 100644 --- a/src/Modules/LAS/Services/LASService.cs +++ b/src/Modules/LAS/Services/LASService.cs @@ -31,12 +31,15 @@ public async Task RegistreraPeriodAsync( if (accumulation is null) { accumulation = LASAccumulation.Skapa(anstallId, typ); - accumulation.LaggTillPeriod(startDatum, slutDatum); + accumulation.LaggTillPeriod(startDatum, slutDatum, form: typ); await _repository.AddAsync(accumulation, ct); } else { - accumulation.LaggTillPeriod(startDatum, slutDatum); + // SAVA- och vikariatstid ackumuleras separat — periodens form skickas med + // så att dagarna räknas mot rätt gräns (365 resp. 730 dagar), även när + // formen skiljer sig från ackumuleringens ursprungsform. + accumulation.LaggTillPeriod(startDatum, slutDatum, form: typ); await _repository.UpdateAsync(accumulation, ct); } diff --git a/src/Modules/Leave/Domain/SickLeaveNotification.cs b/src/Modules/Leave/Domain/SickLeaveNotification.cs index b333871..2b406c3 100644 --- a/src/Modules/Leave/Domain/SickLeaveNotification.cs +++ b/src/Modules/Leave/Domain/SickLeaveNotification.cs @@ -57,4 +57,40 @@ public void UppdateraDag(int dagNr) LakarintygKravs = dagNr >= 8; FKAnmalanKravs = dagNr >= 15; } + + /// + /// Berknar aktuellt sjukdagnummer utifrn startdatum: dag 1 = startdatum. + /// Avslutade sjukfall rknas t.o.m. slutdatum, pgende t.o.m. angivet datum. + /// + public int BeraknaSjukDag(DateOnly idag) + { + var till = SlutDatum.HasValue && SlutDatum.Value < idag ? SlutDatum.Value : idag; + if (till < StartDatum) return 1; + return till.DayNumber - StartDatum.DayNumber + 1; + } + + /// + /// Synkroniserar och lagkravsflaggorna (lkarintyg dag 8, + /// FK-anmlan dag 15) mot angivet datum. Anropas dagligen av bakgrundsjobbet + /// fr ppna sjukfall samt vid registrering av sjukanmlan med retroaktiv start. + /// + public void SynkroniseraDag(DateOnly idag) => UppdateraDag(BeraknaSjukDag(idag)); + + /// True om sjukfallet r avslutat (friskanmlt). + public bool ArAvslutad => SlutDatum.HasValue; + + /// + /// Friskanmlan: avslutar sjukfallet med angiven sista sjukdag och fryser + /// dagrkningen dr, s att pminnelser och auto-rehab slutar rkna vidare. + /// + public void Avsluta(DateOnly sistaSjukdag) + { + if (SlutDatum.HasValue) + throw new InvalidOperationException("Sjukfallet r redan avslutat."); + if (sistaSjukdag < StartDatum) + throw new ArgumentException("Sista sjukdagen kan inte vara fre startdatum.", nameof(sistaSjukdag)); + + SlutDatum = sistaSjukdag; + UppdateraDag(BeraknaSjukDag(sistaSjukdag)); + } } diff --git a/src/Modules/Payroll/Domain/SvenskaHelgdagar.cs b/src/Modules/Payroll/Domain/SvenskaHelgdagar.cs index caaeb3f..98401ff 100644 --- a/src/Modules/Payroll/Domain/SvenskaHelgdagar.cs +++ b/src/Modules/Payroll/Domain/SvenskaHelgdagar.cs @@ -18,10 +18,13 @@ public static bool ArHelgdag(DateOnly datum) } /// - /// Avgör om ett datum infaller under en storhelgsperiod. - /// Storhelg: julafton-annandag jul, nyårsafton-nyårsdagen, - /// påskafton-annandag påsk, midsommarafton-midsommardagen, - /// Kristi himmelsfärd. + /// Avgör om ett datum (hela dygnet) infaller under en storhelgsperiod + /// (O-tilläggstid A enligt AB 25 § 21): + /// julafton–annandag jul, nyårsafton–nyårsdagen, + /// långfredagen–annandag påsk samt midsommarafton–söndagen efter midsommardagen. + /// Kristi himmelsfärdsdag är helgdag (O-tilläggstid B) men INTE storhelg. + /// Kanttimmarna (från kl. 18.00 dagen före långfredagen respektive till kl. 07.00 + /// måndagen efter midsommarhelgen) hanteras av . /// public static bool ArStorhelg(DateOnly datum) { @@ -42,21 +45,45 @@ public static bool ArStorhelg(DateOnly datum) if (datum == new DateOnly(year, 1, 1)) return true; - // Påskafton - Annandag påsk - var paskafton = paskdagen.AddDays(-1); + // Långfredagen - Annandag påsk (AB 25 § 21: O-tilläggstid A börjar redan + // kl. 18.00 dagen före långfredagen — timkanten hanteras i tidsöverlasten) + var langfredagen = paskdagen.AddDays(-2); var annandagPask = paskdagen.AddDays(1); - if (datum >= paskafton && datum <= annandagPask) + if (datum >= langfredagen && datum <= annandagPask) return true; - // Midsommarafton - Midsommardagen + // Midsommarafton - söndagen efter midsommardagen (A-tiden löper till kl. 07.00 + // på måndagen — timkanten hanteras i tidsöverlasten) var midsommardagen = BeraknaMidsommardagen(year); var midsommarafton = midsommardagen.AddDays(-1); - if (datum >= midsommarafton && datum <= midsommardagen) + var sondagenEfterMidsommar = midsommardagen.AddDays(1); + if (datum >= midsommarafton && datum <= sondagenEfterMidsommar) return true; - // Kristi himmelsfärd (torsdag, 39 dagar efter påskdagen) - var kristiHimmelsfardsdag = paskdagen.AddDays(39); - if (datum == kristiHimmelsfardsdag) + // Kristi himmelsfärdsdag ingår INTE i O-tilläggstid A enligt AB 25 § 21 + // (den är helgdag → O-tilläggstid B). + return false; + } + + /// + /// Tidsmedveten storhelgsbedömning (O-tilläggstid A enligt AB 25 § 21). + /// Utöver hela storhelgsdygnen ingår kanterna: + /// från kl. 18.00 dagen före långfredagen (skärtorsdagen) och + /// till kl. 07.00 på måndagen efter midsommarhelgen. + /// + public static bool ArStorhelg(DateOnly datum, TimeOnly tid) + { + if (ArStorhelg(datum)) + return true; + + // Skärtorsdagen (dagen före långfredagen) från kl. 18.00 + var paskdagen = BeraknaPaskdagen(datum.Year); + if (datum == paskdagen.AddDays(-3) && tid >= new TimeOnly(18, 0)) + return true; + + // Måndagen efter midsommarhelgen till kl. 07.00 + var midsommardagen = BeraknaMidsommardagen(datum.Year); + if (datum == midsommardagen.AddDays(2) && tid < new TimeOnly(7, 0)) return true; return false; @@ -102,41 +129,46 @@ public static IReadOnlyList HelgdagarForAr(int year) } /// - /// Bestäm OB-kategori baserat på datum och tid. - /// Storhelg > helg > vardag kväll/natt > ingen. + /// Bestäm OB-kategori baserat på datum och tid enligt AB 25 § 21. + /// Prioritet: storhelg (A) > helg (B) > vardagsnatt (C) > vardagskväll (D) > ingen. /// - /// OB-tider per AB 25 (Allmänna bestämmelser, från 2025-04-01): - /// - Vardagkväll: mån-tors 19:00-22:00, fre 17:00-22:00 (AB 25: fredag OB från 17:00) - /// - Vardagnatt: mån-fre 22:00-06:00 - /// - Helg: lör 07:00 - sön 24:00 (samt helgdagar) - /// - Storhelg: storhelgsperioder dygnet runt - /// - /// Före 2025-04-01: fredag kväll OB börjar kl 19:00 (som övriga vardagar). + /// O-tilläggstider per AB 25 (Allmänna bestämmelser, i lydelse 2025-04-01): + /// - A (storhelg): storhelgsperioderna inkl. kanterna (se ) + /// - B (helg): helgdag/lördag/söndag hela dygnet, fredag från kl. 17:00 till 24:00 + /// (före 2025-04-01: från kl. 19:00) samt måndag 00:00–07:00 + /// - C (vardagsnatt): övriga vardagar 22:00–06:00 + /// - D (vardagskväll): övriga vardagar 19:00–22:00 /// public static OBCategory BeraknaOBKategori(DateOnly datum, TimeOnly tid) { - // Storhelg har högst prioritet - if (ArStorhelg(datum)) + // O-tilläggstid A (storhelg) har högst prioritet — tidsmedveten så att + // kanterna (skärtorsdag 18:00, måndag efter midsommar till 07:00) träffas. + if (ArStorhelg(datum, tid)) return OBCategory.Storhelg; - // Helgdag (röd dag som inte är storhelg) eller lördag/söndag + // O-tilläggstid B: helgdag (röd dag som inte är storhelg) eller lördag/söndag, hela dygnet if (ArHelgdag(datum) || datum.DayOfWeek == DayOfWeek.Saturday || datum.DayOfWeek == DayOfWeek.Sunday) return OBCategory.Helg; - // Vardagstider (måndag-fredag) - // Natt: 22:00-06:00 - if (tid >= new TimeOnly(22, 0) || tid < new TimeOnly(6, 0)) - return OBCategory.VardagNatt; - - // AB 25: Fredag kväll OB från 17:00 (istället för 19:00), gäller från 2025-04-01 - var kvallStart = new TimeOnly(19, 0); - if (datum.DayOfWeek == DayOfWeek.Friday && datum >= new DateOnly(2025, 4, 1)) + // O-tilläggstid B: fredag från kl. 17:00 (AB 25, fr.o.m. 2025-04-01; tidigare 19:00) + // till 24:00 — helgtid, inte vardagskväll (D). + if (datum.DayOfWeek == DayOfWeek.Friday) { - kvallStart = new TimeOnly(17, 0); + var helgStart = datum >= new DateOnly(2025, 4, 1) ? new TimeOnly(17, 0) : new TimeOnly(19, 0); + if (tid >= helgStart) + return OBCategory.Helg; } - // Kväll: kvallStart-22:00 - if (tid >= kvallStart && tid < new TimeOnly(22, 0)) + // O-tilläggstid B: måndag 00:00–07:00 (helgtiden löper till kl. 07 på måndagen) + if (datum.DayOfWeek == DayOfWeek.Monday && tid < new TimeOnly(7, 0)) + return OBCategory.Helg; + + // O-tilläggstid C: vardagsnatt 22:00–06:00 + if (tid >= new TimeOnly(22, 0) || tid < new TimeOnly(6, 0)) + return OBCategory.VardagNatt; + + // O-tilläggstid D: vardagskväll 19:00–22:00 + if (tid >= new TimeOnly(19, 0) && tid < new TimeOnly(22, 0)) return OBCategory.VardagKvall; // Dagtid vardag: ingen OB diff --git a/src/Modules/Payroll/Engine/PayrollCalculationEngine.cs b/src/Modules/Payroll/Engine/PayrollCalculationEngine.cs index fcc0f49..702b612 100644 --- a/src/Modules/Payroll/Engine/PayrollCalculationEngine.cs +++ b/src/Modules/Payroll/Engine/PayrollCalculationEngine.cs @@ -1,3 +1,4 @@ +using RegionHR.Agreements.Domain; using RegionHR.Core.Contracts; using RegionHR.SharedKernel.Domain; using RegionHR.Payroll.Domain; @@ -10,15 +11,17 @@ namespace RegionHR.Payroll.Engine; /// /// Pipeline: /// 1. Grundlön (månadslön * sysselsättningsgrad) -/// 2. Tillägg (OB, övertid, jour) -/// 3. Frånvaroavdrag (sjuklön, semester) -/// 4. Bruttolön -/// 5. Skatteavdrag (skattetabell) -/// 6. Nettolöneavdrag (utmätning, fackavgift) -/// 7. Nettolön -/// 8. Arbetsgivaravgifter -/// 9. Pension (AKAP-KR) -/// 10. Semesterlöneskuld +/// 2. Tillägg (OB inkl. natthöjda satser, övertid, jour, beredskap) +/// 3. Frånvaroavdrag (sjukavdrag, karensavdrag, föräldraledighetsavdrag, semester) +/// — frånvaro sänker alltid brutto, sjuklön/utfyllnad kompenserar delvis +/// 4. Skattefria resersättningar (attesterade resekrav) +/// 5. Bruttolön +/// 6. Skatteavdrag (skattetabell, på skattepliktigt brutto) +/// 7. Nettolöneavdrag (utmätning, fackavgift) +/// 8. Nettolön +/// 9. Arbetsgivaravgifter (på avgiftspliktigt underlag) +/// 10. Pension (AKAP-KR) +/// 11. Semesterlöneskuld /// public sealed class PayrollCalculationEngine { @@ -93,31 +96,72 @@ public async Task CalculateAsync( Kostnadsstalle = input.Kostnadsstalle }); - // Steg 2: OB-tillägg + // Steg 2: OB-tillägg (O-tillägg enligt AB § 21). + // Grundsatsen per kategori hämtas ur regelverket (kollektivavtal + ev. lokala avvikelser). + // För O-tilläggstid A (storhelg) och B (helg) tillämpas den natthöjda satsen kl. 22–06 + // ur den kanoniska tabellen ABOTillaggSatser (AB § 21 anm.), så att natt-timmar + // aldrig prissätts med den lägre grundsatsen. var obTillagg = Money.Zero; if (input.OBTimmar.Count > 0) { - obTillagg = await BeraknaOBTillaggAsync(employment.Kollektivavtal, input.OBTimmar, ct); - result.OBTillagg = obTillagg; + var periodDatum = new DateOnly(year, month, 1); + var arABAvtal = employment.Kollektivavtal + is CollectiveAgreementType.AB or CollectiveAgreementType.HOK; + foreach (var ob in input.OBTimmar) { - var sats = await _rulesEngine.GetOBRateAsync(employment.Kollektivavtal, ob.Kategori, new DateOnly(year, month, 1), ct); - result.LaggTillRad(new PayrollResultLine + if (ob.Timmar <= 0m) + continue; + + var grundsats = await _rulesEngine.GetOBRateAsync(employment.Kollektivavtal, ob.Kategori, periodDatum, ct); + + // Natthöjning finns endast för helg (B) och storhelg (A). + var nattTimmar = ob.Kategori is OBCategory.Helg or OBCategory.Storhelg + ? Math.Clamp(ob.NattTimmar, 0m, ob.Timmar) + : 0m; + var dagTimmar = ob.Timmar - nattTimmar; + + if (dagTimmar > 0m) + { + var belopp = Money.SEK(dagTimmar * grundsats); + obTillagg += belopp; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "1310", Benamning = $"OB-tillägg {ob.Kategori}", + Antal = dagTimmar, Sats = Money.SEK(grundsats), + Belopp = belopp, + Skattekategori = TaxCategory.Skattepliktig, + ArSemestergrundande = true, ArPensionsgrundande = true + }); + } + + if (nattTimmar > 0m) { - LoneartKod = "1310", Benamning = $"OB-tillägg {ob.Kategori}", - Antal = ob.Timmar, Sats = Money.SEK(sats), - Belopp = Money.SEK(ob.Timmar * sats), - Skattekategori = TaxCategory.Skattepliktig, - ArSemestergrundande = true, ArPensionsgrundande = true - }); + var nattsats = arABAvtal + ? ABOTillaggSatser.Nattsats(ob.Kategori, periodDatum) + : grundsats; + var belopp = Money.SEK(nattTimmar * nattsats); + obTillagg += belopp; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "1310", Benamning = $"OB-tillägg {ob.Kategori} natt", + Antal = nattTimmar, Sats = Money.SEK(nattsats), + Belopp = belopp, + Skattekategori = TaxCategory.Skattepliktig, + ArSemestergrundande = true, ArPensionsgrundande = true + }); + } } + + result.OBTillagg = obTillagg; } // Steg 2b: Jour var jourTillagg = Money.Zero; if (input.JourTimmar > 0) { - var timlon = employment.Manadslon / (38.25m * 52m / 12m); + // Timlön enligt AB: månadslön / 165 (samma delare som övertid, AB § 20 mom. 3) + var timlon = ABOvertidSatser.Overtidstimlon(employment.Manadslon); var jourRegler = await _rulesEngine.GetJourReglerAsync(employment.Kollektivavtal, new DateOnly(year, month, 1), ct); var jourSats = timlon * jourRegler.PassivTimlonFaktor; jourTillagg = Money.SEK(input.JourTimmar * jourSats); @@ -136,7 +180,8 @@ public async Task CalculateAsync( var beredskapsTillagg = Money.Zero; if (input.BeredskapsTimmar > 0) { - var timlon = employment.Manadslon / (38.25m * 52m / 12m); + // Timlön enligt AB: månadslön / 165 + var timlon = ABOvertidSatser.Overtidstimlon(employment.Manadslon); var beredskapsRegler = await _rulesEngine.GetBeredskapsReglerAsync(employment.Kollektivavtal, new DateOnly(year, month, 1), ct); var beredskapsSats = timlon * beredskapsRegler.PassivTimlonFaktor; beredskapsTillagg = Money.SEK(input.BeredskapsTimmar * beredskapsSats); @@ -152,13 +197,17 @@ public async Task CalculateAsync( } // Steg 3: Övertid + // AB § 20 mom. 3: övertidskompensation per övertidstimme = 180 % (enkel) resp. + // 240 % (kvalificerad) av timlönen (månadslön / 165). Övertidstimmarna ligger + // UTANFÖR den ordinarie arbetstid som månadslönen täcker, därför utges hela + // beloppet (inte bara tillägget ovanpå en redan betald timme). var overtid = Money.Zero; if (input.OvertidTimmar > 0) { - var timlon = employment.Manadslon / (38.25m * 52m / 12m); - // AB 25: Enkel övertid = 180% total (tillägg 80%), Kvalificerad = 240% total (tillägg 140%) - // Grundlönen täcker bas 100%, så tillägget är 0.8x resp 1.4x timlön - var overtidsSats = input.KvalificeradOvertid ? timlon * 1.4m : timlon * 0.8m; + var overtidstimlon = ABOvertidSatser.Overtidstimlon(employment.Manadslon); + var overtidsSats = overtidstimlon * (input.KvalificeradOvertid + ? ABOvertidSatser.KvalificeradOvertidProcent + : ABOvertidSatser.EnkelOvertidProcent); overtid = Money.SEK(input.OvertidTimmar * overtidsSats); result.Overtidstillagg = overtid; result.LaggTillRad(new PayrollResultLine @@ -171,7 +220,28 @@ public async Task CalculateAsync( }); } - // Steg 4: Sjuklön / Karensavdrag + // Steg 4: Sjukfrånvaro — sjukavdrag, karensavdrag och sjuklön. + // Månadslönen betalas ut i sin helhet i steg 1, därför görs löneavdrag med full + // daglön för VARJE sjukfrånvarodag (AB § 28 / Sjuklönelagen). Sjuklön 80 % utges + // för dag 2–14; dag 15+ ersätts av Försäkringskassan men löneavdraget kvarstår. + // Nettoeffekten är att en sjukmånad alltid ger LÄGRE brutto än en frisk månad. + var arbetsdagarIManaden = Math.Max(1, input.ArbetsdagarIManadens); + var sjukavdrag = Money.Zero; + var sjukdagarTotalt = input.SjukdagarMedLon + input.SjukdagarUtanLon; + if (sjukdagarTotalt > 0) + { + var avdragDaglon = Money.SEK(employment.Manadslon * employment.Sysselsattningsgrad / 100m / arbetsdagarIManaden); + sjukavdrag = avdragDaglon * sjukdagarTotalt; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "3005", Benamning = "Sjukavdrag", + Antal = sjukdagarTotalt, Sats = avdragDaglon.RoundToOren(), + Belopp = Money.Zero - sjukavdrag, + Skattekategori = TaxCategory.Skattepliktig, ArAvdrag = true, + Kostnadsstalle = input.Kostnadsstalle + }); + } + if (input.SjukdagarMedLon > 0) { var veckolon = Money.SEK(employment.Manadslon * employment.Sysselsattningsgrad / 100m * 12m / 52m); @@ -198,10 +268,25 @@ public async Task CalculateAsync( }); } - // Steg 4b: Föräldralöneutfyllnad per AB + // Steg 4b: Föräldraledighet — löneavdrag för hela den lediga perioden plus + // föräldralöneutfyllnad per AB. Den lediga tiden är obetald (Försäkringskassan + // betalar föräldrapenning); arbetsgivaren drar full daglön och lägger till + // utfyllnaden (10 % av daglönen) för dagar med utfyllnadsrätt. + var foraldraledighetsavdrag = Money.Zero; var foraldraloneUtfyllnad = Money.Zero; if (input.ForaldraledigaDagar > 0) { + var avdragDaglon = Money.SEK(employment.Manadslon * employment.Sysselsattningsgrad / 100m / arbetsdagarIManaden); + foraldraledighetsavdrag = avdragDaglon * input.ForaldraledigaDagar; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "3210", Benamning = "Föräldraledighetsavdrag", + Antal = input.ForaldraledigaDagar, Sats = avdragDaglon.RoundToOren(), + Belopp = Money.Zero - foraldraledighetsavdrag, + Skattekategori = TaxCategory.Skattepliktig, ArAvdrag = true, + Kostnadsstalle = input.Kostnadsstalle + }); + var foraldraRegler = await _rulesEngine.GetForaldraloneReglerAsync(employment.Kollektivavtal, new DateOnly(year, month, 1), ct); // Begränsa till maximalt antal dagar med utfyllnad var dagarMedUtfyllnad = Math.Min(input.ForaldraledigaDagar, foraldraRegler.DagarMedUtfyllnad); @@ -251,10 +336,54 @@ public async Task CalculateAsync( }); } - // Steg 6: Beräkna brutto + // Steg 5b: Resersättningar från attesterade resekrav (traktamente, milersättning, utlägg). + // Satserna följer Skatteverkets schablonbelopp och är därmed skattefria kostnads- + // ersättningar: de ingår i utbetalningen men inte i det skattepliktiga underlaget + // och är varken semester- eller pensionsgrundande. + var reseErsattningSkattefri = Money.Zero; + if (input.ReseTraktamente > Money.Zero) + { + reseErsattningSkattefri += input.ReseTraktamente; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "5100", Benamning = "Inrikes traktamente", + Antal = 1, Sats = input.ReseTraktamente, Belopp = input.ReseTraktamente, + Skattekategori = TaxCategory.Traktamente, + Kostnadsstalle = input.Kostnadsstalle + }); + } + if (input.ReseMilersattning > Money.Zero) + { + reseErsattningSkattefri += input.ReseMilersattning; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "5200", Benamning = "Milersättning", + Antal = 1, Sats = input.ReseMilersattning, Belopp = input.ReseMilersattning, + Skattekategori = TaxCategory.Milersattning, + Kostnadsstalle = input.Kostnadsstalle + }); + } + if (input.ReseUtlagg > Money.Zero) + { + reseErsattningSkattefri += input.ReseUtlagg; + result.LaggTillRad(new PayrollResultLine + { + LoneartKod = "5300", Benamning = "Utlägg (resekrav)", + Antal = 1, Sats = input.ReseUtlagg, Belopp = input.ReseUtlagg, + Skattekategori = TaxCategory.Skattefri, + Kostnadsstalle = input.Kostnadsstalle + }); + } + + // Steg 6: Beräkna brutto. Frånvaroavdragen (sjukavdrag, karensavdrag och + // föräldraledighetsavdrag) dras här — en sjuk- eller föräldraledig månad ger + // alltid LÄGRE (aldrig högre) brutto än en fullt arbetad månad. result.Brutto = grundlon + obTillagg + jourTillagg + beredskapsTillagg + overtid - + result.Sjuklon + result.Semestertillagg + foraldraloneUtfyllnad - result.Karensavdrag; - result.SkattepliktBrutto = result.Brutto; // Förenklad, alla delar skattepliktiga i detta steg + + result.Sjuklon + result.Semestertillagg + foraldraloneUtfyllnad + + reseErsattningSkattefri + - result.Karensavdrag - sjukavdrag - foraldraledighetsavdrag; + // Skattepliktigt brutto: allt utom skattefria kostnadsersättningar (resekraven). + result.SkattepliktBrutto = result.Brutto - reseErsattningSkattefri; // Steg 7: Skatteberäkning enligt Skatteverkets skattetabell. // Systemet är experten: saknas skatteuppgifter på den anställde faller vi tillbaka @@ -284,9 +413,11 @@ public async Task CalculateAsync( // Steg 9: Netto result.Netto = result.Brutto - result.Skatt - result.Loneutmatning - result.Fackavgift - result.OvrigaAvdrag; - // Steg 10: Arbetsgivaravgifter (satsen beror på födelseår + inkomstår/månad) + // Steg 10: Arbetsgivaravgifter (satsen beror på födelseår + inkomstår/månad). + // Underlaget är den avgiftspliktiga ersättningen — skattefria kostnadsersättningar + // (traktamente, milersättning, utlägg) ingår inte. result.ArbetsgivaravgiftSats = BeraknaArbetsgivaravgiftSats(employee, year, month); - result.Arbetsgivaravgifter = BeraknaArbetsgivaravgiftBelopp(result.Brutto, employee, year, month).RoundToKronor(); + result.Arbetsgivaravgifter = BeraknaArbetsgivaravgiftBelopp(result.SkattepliktBrutto, employee, year, month).RoundToKronor(); // Steg 11: Pension AKAP-KR result.Pensionsgrundande = Money.SEK(result.Rader @@ -310,18 +441,6 @@ private static Money BeraknaGrundlon(decimal manadslon, decimal sysselsattningsg return Money.SEK(fullLon * arbetadeDagar / arbetsdagarIManaden); } - private async Task BeraknaOBTillaggAsync( - CollectiveAgreementType avtal, List obTimmar, CancellationToken ct) - { - var total = 0m; - foreach (var ob in obTimmar) - { - var rate = await _rulesEngine.GetOBRateAsync(avtal, ob.Kategori, DateOnly.FromDateTime(DateTime.Today), ct); - total += ob.Timmar * rate; - } - return Money.SEK(total); - } - /// /// Beräkna arbetsgivaravgiftssats för en anställd. Satsen härleds ur födelseåret /// (maskerat personnummer) och gällande inkomstår/månad via domänen @@ -429,6 +548,13 @@ public sealed class PayrollInput public decimal OvertidTimmar { get; set; } public bool KvalificeradOvertid { get; set; } public int SjukdagarMedLon { get; set; } + + /// + /// Sjukfrånvarodagar utan sjuklön (dag 15+, ersätts av Försäkringskassan). + /// Löneavdrag görs även för dessa dagar. + /// + public int SjukdagarUtanLon { get; set; } + public int SemesterdagarUttagna { get; set; } public Money Loneutmatning { get; set; } = Money.Zero; public Money Fackavgift { get; set; } = Money.Zero; @@ -436,10 +562,22 @@ public sealed class PayrollInput public decimal JourTimmar { get; set; } public decimal BeredskapsTimmar { get; set; } public int ForaldraledigaDagar { get; set; } + + // Resersättningar från attesterade (klara för utbetalning) resekrav i Travel-modulen. + // Satserna följer Skatteverkets schablonbelopp → skattefria kostnadsersättningar. + public Money ReseTraktamente { get; set; } = Money.Zero; + public Money ReseMilersattning { get; set; } = Money.Zero; + public Money ReseUtlagg { get; set; } = Money.Zero; } public sealed class OBInput { public OBCategory Kategori { get; set; } public decimal Timmar { get; set; } + + /// + /// Andel av som ligger kl. 22.00–06.00. Används för den + /// natthöjda O-satsen för helg (B) och storhelg (A) enligt AB § 21 anm. + /// + public decimal NattTimmar { get; set; } } diff --git a/src/Modules/SalaryReview/Domain/SalaryReviewRound.cs b/src/Modules/SalaryReview/Domain/SalaryReviewRound.cs index 53335fb..5cd67ba 100644 --- a/src/Modules/SalaryReview/Domain/SalaryReviewRound.cs +++ b/src/Modules/SalaryReview/Domain/SalaryReviewRound.cs @@ -56,6 +56,11 @@ public SalaryProposal LaggTillForslag( throw new InvalidOperationException("Motivering måste anges för löneförslaget."); var okning = foreslagenLon - nuvarandeLon; + // Spärr mot oavsiktlig lönesänkning: en negativ ökning skulle dessutom tyst + // "frigöra" budget (FordeladBudget minskar) och snedvrida rundans utrymme. + if (okning.Amount < 0) + throw new InvalidOperationException( + "Föreslagen lön är lägre än nuvarande lön — lönesänkning kan inte hanteras i en löneöversynsrunda."); if (FordeladBudget + okning > TotalBudget) throw new InvalidOperationException("Budget överskriden"); diff --git a/src/Modules/Scheduling/Domain/ArbetstidslagenValidator.cs b/src/Modules/Scheduling/Domain/ArbetstidslagenValidator.cs index 4fdedfb..7501672 100644 --- a/src/Modules/Scheduling/Domain/ArbetstidslagenValidator.cs +++ b/src/Modules/Scheduling/Domain/ArbetstidslagenValidator.cs @@ -84,7 +84,7 @@ public ValidationResult ValidateShift( ViolationSeverity.Hard)); } - if (!UppfyllerVeckovila(anstallId, newShift.Datum, existing)) + if (!UppfyllerVeckovila(anstallId, newShift.Datum, existing, newShift)) { violations.Add(new ValidationViolation( "ATL §14 Veckovila", @@ -272,11 +272,14 @@ public bool UppfyllerDygnsvila( /// /// Kontrollera veckovila: minst 36 timmars sammanhängande vila per 7-dagarsperiod. + /// Det nya passet (om angivet) räknas in i veckan, så att kontrollen validerar + /// veckovilan som den blir EFTER att passet lagts in — oavsett antal pass i veckan. /// public bool UppfyllerVeckovila( EmployeeId anstallId, DateOnly datum, - IReadOnlyList existing) + IReadOnlyList existing, + ShiftAssignment? nyttPass = null) { // Beräkna veckans start (måndag) var veckoStart = datum.AddDays(-(int)datum.DayOfWeek + (datum.DayOfWeek == DayOfWeek.Sunday ? -6 : 1)); @@ -284,11 +287,17 @@ public bool UppfyllerVeckovila( var passIVeckan = existing .Where(a => a.AnstallId == anstallId && a.Datum >= veckoStart && a.Datum < veckoSlut) - .OrderBy(a => a.Datum).ThenBy(a => a.Start) .ToList(); - // Om 6 eller fler pass i veckan kontrolleras att det finns en lucka på >= 36h - if (passIVeckan.Count < 6) return true; + // Det nya passet måste ingå — annars godkänns pass som raderar veckovilan. + if (nyttPass is not null && nyttPass.Datum >= veckoStart && nyttPass.Datum < veckoSlut) + passIVeckan.Add(nyttPass); + + if (passIVeckan.Count == 0) return true; + + passIVeckan = passIVeckan + .OrderBy(a => a.Datum).ThenBy(a => a.Start) + .ToList(); return HarTillrackligVeckovila(passIVeckan, veckoStart, veckoSlut); } diff --git a/src/Modules/Scheduling/Domain/Schedule.cs b/src/Modules/Scheduling/Domain/Schedule.cs index 0a428d4..8acece1 100644 --- a/src/Modules/Scheduling/Domain/Schedule.cs +++ b/src/Modules/Scheduling/Domain/Schedule.cs @@ -1,3 +1,4 @@ +using RegionHR.Payroll.Domain; using RegionHR.SharedKernel.Abstractions; using RegionHR.SharedKernel.Domain; @@ -63,12 +64,48 @@ public ScheduledShift LaggTillPass( PlaneradStart = start, PlaneradSlut = slut, Rast = rast, - Status = ShiftStatus.Planerad + Status = ShiftStatus.Planerad, + OBKategori = BeraknaOBKategoriForPass(datum, start, slut) }; _pass.Add(shift); return shift; } + /// + /// Bestäm passets OB-kategori utifrån planerad tid. Går igenom passet i + /// 15-minutersintervall (samma upplösning som SchedulePayrollBridge) och väljer + /// den högst prioriterade kategori som förekommer: storhelg > helg > natt > kväll. + /// Hanterar nattpass som korsar midnatt. Ett vardagspass utan OB-tid får + /// . + /// + private static OBCategory BeraknaOBKategoriForPass(DateOnly datum, TimeOnly start, TimeOnly slut) + { + var startDT = datum.ToDateTime(start); + var slutDT = datum.ToDateTime(slut); + if (slutDT <= startDT) + slutDT = slutDT.AddDays(1); // Nattpass som korsar midnatt + + var kategori = OBCategory.Ingen; + for (var current = startDT; current < slutDT; current = current.AddMinutes(15)) + { + var punktKategori = SvenskaHelgdagar.BeraknaOBKategori( + DateOnly.FromDateTime(current), TimeOnly.FromDateTime(current)); + if (OBPrioritet(punktKategori) > OBPrioritet(kategori)) + kategori = punktKategori; + } + + return kategori; + } + + private static int OBPrioritet(OBCategory kategori) => kategori switch + { + OBCategory.Storhelg => 4, + OBCategory.Helg => 3, + OBCategory.VardagNatt => 2, + OBCategory.VardagKvall => 1, + _ => 0 + }; + /// /// Ta bort ett planerat pass ur schemat. Returnerar true om passet fanns och togs bort. /// diff --git a/src/Modules/Scheduling/Optimization/ConstraintScheduleSolver.cs b/src/Modules/Scheduling/Optimization/ConstraintScheduleSolver.cs index 60ef092..ce67477 100644 --- a/src/Modules/Scheduling/Optimization/ConstraintScheduleSolver.cs +++ b/src/Modules/Scheduling/Optimization/ConstraintScheduleSolver.cs @@ -1,3 +1,4 @@ +using RegionHR.Agreements.Domain; using RegionHR.SharedKernel.Domain; using RegionHR.Scheduling.Domain; @@ -345,17 +346,22 @@ private static double BeraknaRattviseScore( private static Money BeraknaOBKostnad(List tilldelningar) { + // Kanoniska, årsversionerade O-tilläggssatser enligt AB § 21 (ABOTillaggSatser) — + // inga hårdkodade belopp. Helgdygn (lör/sön) prissätts som O-tilläggstid B + // oavsett passtyp; vardagskväll/-natt som D respektive C. var total = 0m; foreach (var t in tilldelningar) { - total += t.PassTyp switch - { - ShiftType.Kvall => t.PlaneradeTimmar * 126.50m, - ShiftType.Natt => t.PlaneradeTimmar * 152.00m, - _ when t.Datum.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday - => t.PlaneradeTimmar * 100.00m, // Helg-OB - _ => 0m - }; + var kategori = t.Datum.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday + ? OBCategory.Helg + : t.PassTyp switch + { + ShiftType.Kvall => OBCategory.VardagKvall, + ShiftType.Natt => OBCategory.VardagNatt, + _ => OBCategory.Ingen + }; + + total += t.PlaneradeTimmar * ABOTillaggSatser.Grundsats(kategori, t.Datum); } return Money.SEK(total); } diff --git a/src/Modules/Wellness/Domain/WellnessClaim.cs b/src/Modules/Wellness/Domain/WellnessClaim.cs index a29f8e8..7cf12c0 100644 --- a/src/Modules/Wellness/Domain/WellnessClaim.cs +++ b/src/Modules/Wellness/Domain/WellnessClaim.cs @@ -8,6 +8,9 @@ public enum WellnessClaimStatus { Inskickad, Godkand, Avslagen } /// public sealed class WellnessClaim { + /// Maximalt friskvårdsbidrag per anställd och kalenderår (Skatteverkets schablon). + public const decimal MaxBeloppPerAr = 5000m; + public Guid Id { get; private set; } public Guid AnstallId { get; private set; } public string Aktivitet { get; private set; } = default!; @@ -22,11 +25,17 @@ public sealed class WellnessClaim private WellnessClaim() { } - public static WellnessClaim Skapa(Guid anstallId, string aktivitet, decimal belopp, DateOnly datum, string? kvittoFilId = null) + /// + /// Skapar en ny ansökan. är summan av årets + /// redan godkända ansökningar för den anställde — ansökan avvisas om beloppet skulle + /// spränga årstaket på kr. + /// + public static WellnessClaim Skapa(Guid anstallId, string aktivitet, decimal belopp, DateOnly datum, string? kvittoFilId = null, decimal tidigareGodkantUnderAret = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(aktivitet); if (anstallId == Guid.Empty) throw new ArgumentException("AnstallId krävs.", nameof(anstallId)); if (belopp <= 0) throw new ArgumentOutOfRangeException(nameof(belopp)); + ValideraArstak(belopp, tidigareGodkantUnderAret); return new WellnessClaim { @@ -41,16 +50,33 @@ public static WellnessClaim Skapa(Guid anstallId, string aktivitet, decimal belo }; } - public void Godkann(Guid godkannare, string? kommentar = null) + /// + /// Godkänner ansökan. är summan av årets + /// redan godkända ansökningar för den anställde (exklusive denna) — godkännandet + /// avvisas om årstaket på kr skulle överskridas. + /// + public void Godkann(Guid godkannare, string? kommentar = null, decimal tidigareGodkantUnderAret = 0) { if (Status != WellnessClaimStatus.Inskickad) throw new InvalidOperationException($"Kan bara godkänna inskickad ansökan. Nuvarande: {Status}"); + ValideraArstak(Belopp, tidigareGodkantUnderAret); Status = WellnessClaimStatus.Godkand; GodkandAv = godkannare; GodkandVid = DateTime.UtcNow; Kommentar = kommentar; } + private static void ValideraArstak(decimal belopp, decimal tidigareGodkantUnderAret) + { + if (tidigareGodkantUnderAret < 0) + throw new ArgumentOutOfRangeException(nameof(tidigareGodkantUnderAret)); + if (tidigareGodkantUnderAret + belopp > MaxBeloppPerAr) + throw new InvalidOperationException( + $"Friskvårdsbidraget är max {MaxBeloppPerAr:N0} kr per kalenderår. " + + $"Redan godkänt i år: {tidigareGodkantUnderAret:N0} kr — kvar att nyttja: " + + $"{Math.Max(0, MaxBeloppPerAr - tidigareGodkantUnderAret):N0} kr."); + } + public void Avvisa(Guid godkannare, string kommentar) { if (Status != WellnessClaimStatus.Inskickad) diff --git a/src/Web/Components/Pages/Admin/SchemalagdaRapporter.razor b/src/Web/Components/Pages/Admin/SchemalagdaRapporter.razor index e0409a9..17e8bb8 100644 --- a/src/Web/Components/Pages/Admin/SchemalagdaRapporter.razor +++ b/src/Web/Components/Pages/Admin/SchemalagdaRapporter.razor @@ -127,6 +127,14 @@ else if (!KanSkapa) return; var scheduled = ScheduledReport.Skapa(_nyReportTemplateId, _nyFrekvens, _nyMottagare!, _nyFormat); await db.ScheduledReports.AddAsync(scheduled); + + // Markera rapportdefinitionen som schemalagd — ScheduledReportService filtrerar på + // ArSchemalagd + CronExpression, så utan detta körs rapporten aldrig. + // Frekvensnyckelorden (Daily/Weekly/Monthly) tolkas direkt av CronSchedule.TryParse. + var definition = await db.ReportDefinitions + .FirstOrDefaultAsync(r => r.Id == _nyReportTemplateId); + definition?.SattSchemalagd(_nyFrekvens, _nyMottagare!); + await db.SaveChangesAsync(); _schemalagda.Add(scheduled); Snackbar.Add("Schemalagd rapport skapad!", Severity.Success); diff --git a/src/Web/Components/Pages/Anstallda/Index.razor b/src/Web/Components/Pages/Anstallda/Index.razor index e51ac64..bf9ee70 100644 --- a/src/Web/Components/Pages/Anstallda/Index.razor +++ b/src/Web/Components/Pages/Anstallda/Index.razor @@ -3,6 +3,7 @@ @using RegionHR.Infrastructure.Export @using Microsoft.Extensions.Localization @inject RegionHR.Web.Services.AnstallningService Service +@inject RegionHR.Web.Services.UnitScopeService Scope @inject NavigationManager Nav @inject ExportService Export @inject IJSRuntime JS @@ -114,7 +115,21 @@ else _loading = true; try { - _anstallda = await Service.HamtaAllaAsync(string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm); + var alla = await Service.HamtaAllaAsync(string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm); + + // Enhets-scoping (samma mönster som Chef/Index.razor): en chef ser + // bara sin egen enhets medarbetare; HR/Admin ser allt. Fail-closed: + // kan enheten inte härledas för en chef visas INGET i stället för + // hela organisationen. + if (Scope.IsUnitScoped) + { + var scopeIds = await Scope.GetEmployeeIdsInScopeAsync(); + alla = scopeIds is null + ? [] + : alla.Where(a => scopeIds.Contains(a.Id.Value)).ToList(); + } + + _anstallda = alla; } catch { diff --git a/src/Web/Components/Pages/Anstallda/Lonehistorik.razor b/src/Web/Components/Pages/Anstallda/Lonehistorik.razor index ea6974a..0b31145 100644 --- a/src/Web/Components/Pages/Anstallda/Lonehistorik.razor +++ b/src/Web/Components/Pages/Anstallda/Lonehistorik.razor @@ -1,21 +1,245 @@ @page "/anstallda/{Id:guid}/lonehistorik" @layout RegionHR.Web.Components.Layout.AdminLayout +@using System.Text.Json +@using Microsoft.EntityFrameworkCore +@using RegionHR.Audit.Domain +@using RegionHR.SalaryReview.Domain +@inject IDbContextFactory DbFactory OpenHR - Lönehistorik Tillbaka -Lönehistorik +@if (_loading) +{ + +} +else if (_emp is null) +{ + Anställd hittades inte. +} +else +{ + Lönehistorik + @_emp.FulltNamn (@_emp.Personnummer.ToMaskedString()) + + @if (_aktivAnstallning is not null) + { + + Nuvarande månadslön + @($"{_aktivAnstallning.Manadslon.Amount:N0} kr") + + @(_aktivAnstallning.Befattningstitel ?? "—") · @($"{_aktivAnstallning.Sysselsattningsgrad.Value:0.##}") % · anställd sedan @_aktivAnstallning.Giltighetsperiod.Start.ToString("yyyy-MM-dd") + + + } - - DatumTidigare lönNy lönFörändringAnledningGodkänd av - - 2026-01-0128 500 kr30 000 kr+1 500 kr (+5,3%)Löneöversyn 2026Anna HR - 2025-01-0127 000 kr28 500 kr+1 500 kr (+5,6%)Löneöversyn 2025Anna HR - 2023-06-0125 500 kr27 000 kr+1 500 kr (+5,9%)Befordran till SskErik Chef - 2015-08-01—25 500 kr—Nyanställning— - - + @if (_rader.Count == 0) + { + Ingen lönehistorik registrerad för denna anställd. + } + else + { + + DatumTidigare lönNy lönFörändringAnledningÄndrad/godkänd av + + @foreach (var r in _rader) + { + + @r.Datum.ToString("yyyy-MM-dd") + @(r.TidigareLon.HasValue ? $"{r.TidigareLon.Value:N0} kr" : "—") + @($"{r.NyLon:N0} kr") + @if (r.TidigareLon.HasValue) + { + var tidigare = r.TidigareLon.Value; + var diff = r.NyLon - tidigare; + var procent = tidigare > 0 ? diff / tidigare * 100m : 0m; + var tecken = diff >= 0 ? "+" : "-"; + @($"{tecken}{Math.Abs(diff):N0} kr ({tecken}{Math.Abs(procent):0.0}%)") + } + else + { + — + } + @r.Anledning + @r.GodkandAv + + } + + + + Historiken härleds ur granskningsloggen och genomförda löneöversyner. + + } +} @code { [Parameter] public Guid Id { get; set; } + + private Employee? _emp; + private Employment? _aktivAnstallning; + private bool _loading = true; + private List _rader = []; + + protected override async Task OnInitializedAsync() + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var empId = EmployeeId.From(Id); + + _emp = await db.Employees + .Include(e => e.Anstallningar) + .FirstOrDefaultAsync(e => e.Id == empId); + if (_emp is null) + { + _loading = false; + return; + } + var emp = _emp; + + var idag = DateOnly.FromDateTime(DateTime.Today); + _aktivAnstallning = emp.AktivAnstallning(idag) + ?? emp.Anstallningar.OrderByDescending(a => a.Giltighetsperiod.Start).FirstOrDefault(); + + // 1) Verkliga löneändringar ur granskningsloggen (Employment.Manadslon ändrad) + var anstallningsIds = emp.Anstallningar.Select(a => a.Id.ToString()).ToList(); + var auditEntries = await db.AuditEntries + .Where(e => e.EntityType == "Employment" + && anstallningsIds.Contains(e.EntityId) + && e.Action == AuditAction.Update) + .OrderBy(e => e.Timestamp) + .ToListAsync(); + + foreach (var entry in auditEntries) + { + var tidigare = LasLonebelopp(entry.OldValues); + var ny = LasLonebelopp(entry.NewValues); + if (tidigare is null || ny is null || tidigare == ny) continue; + + _rader.Add(new LonRad + { + Datum = DateOnly.FromDateTime(entry.Timestamp.ToLocalTime()), + TidigareLon = tidigare, + NyLon = ny.Value, + Anledning = "Löneändring", + GodkandAv = LasAndradAv(entry), + AnstallningId = Guid.TryParse(entry.EntityId, out var aid) ? aid : null + }); + } + + // 2) Genomförda löneöversynsposter för personen + var rundor = await db.SalaryReviewRounds + .Include(r => r.Forslag) + .Where(r => r.Status == SalaryReviewStatus.Genomford) + .ToListAsync(); + + foreach (var runda in rundor) + { + foreach (var forslag in runda.Forslag.Where(f => + f.AnstallId == empId && f.Status == SalaryProposalStatus.Godkand)) + { + var anledning = $"Löneöversyn {runda.Ar}" + (string.IsNullOrWhiteSpace(runda.Namn) ? "" : $" ({runda.Namn})"); + + // Samma ändring kan redan finnas som löneändring i granskningsloggen — + // berika den raden i stället för att visa en dublett. + var dublett = _rader.FirstOrDefault(r => + r.TidigareLon == forslag.NuvarandeLon.Amount && r.NyLon == forslag.ForeslagenLon.Amount); + if (dublett is not null) + { + dublett.Anledning = anledning; + if (!string.IsNullOrWhiteSpace(runda.FackligRepresentant)) + dublett.GodkandAv = runda.FackligRepresentant; + continue; + } + + _rader.Add(new LonRad + { + Datum = runda.IkrafttradandeDatum, + TidigareLon = forslag.NuvarandeLon.Amount, + NyLon = forslag.ForeslagenLon.Amount, + Anledning = anledning, + GodkandAv = string.IsNullOrWhiteSpace(runda.FackligRepresentant) ? "—" : runda.FackligRepresentant, + AnstallningId = forslag.AnstallningId?.Value + }); + } + } + + // 3) En rad per anställning (nyanställning). Ingångslönen tas från den äldsta + // kända "tidigare lön" för anställningen; saknas ändringar används nuvarande lön. + foreach (var a in emp.Anstallningar) + { + var aldstaKanda = _rader + .Where(r => r.AnstallningId == a.Id.Value + || (r.AnstallningId is null && emp.Anstallningar.Count == 1)) + .OrderBy(r => r.Datum) + .FirstOrDefault(); + + _rader.Add(new LonRad + { + Datum = a.Giltighetsperiod.Start, + TidigareLon = null, + NyLon = aldstaKanda?.TidigareLon ?? a.Manadslon.Amount, + Anledning = a.Befattningstitel is null ? "Nyanställning" : $"Nyanställning — {a.Befattningstitel}", + GodkandAv = "—", + AnstallningId = a.Id.Value + }); + } + + _rader = _rader.OrderByDescending(r => r.Datum).ThenBy(r => r.TidigareLon is null).ToList(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Lonehistorik load error: {ex.Message}"); + } + _loading = false; + } + + /// Läser ut månadslönebeloppet ur granskningsloggens JSON (Money serialiseras som objekt med amount). + private static decimal? LasLonebelopp(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("manadslon", out var lon)) return null; + return lon.ValueKind switch + { + JsonValueKind.Object when lon.TryGetProperty("amount", out var belopp) => belopp.GetDecimal(), + JsonValueKind.Number => lon.GetDecimal(), + _ => null + }; + } + catch (JsonException) + { + return null; + } + } + + private static string LasAndradAv(AuditEntry entry) + { + if (!string.IsNullOrWhiteSpace(entry.NewValues)) + { + try + { + using var doc = JsonDocument.Parse(entry.NewValues); + if (doc.RootElement.TryGetProperty("updatedBy", out var av) && av.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(av.GetString())) + { + return av.GetString()!; + } + } + catch (JsonException) { /* fall tillbaka på loggens användare */ } + } + return entry.UserName ?? entry.UserId; + } + + private sealed class LonRad + { + public DateOnly Datum { get; init; } + public decimal? TidigareLon { get; init; } + public decimal NyLon { get; init; } + public string Anledning { get; set; } = ""; + public string GodkandAv { get; set; } = "—"; + public Guid? AnstallningId { get; init; } + } } diff --git a/src/Web/Components/Pages/Chef/Index.razor b/src/Web/Components/Pages/Chef/Index.razor index 05f99d3..71e2e3a 100644 --- a/src/Web/Components/Pages/Chef/Index.razor +++ b/src/Web/Components/Pages/Chef/Index.razor @@ -174,20 +174,20 @@ else if (!_loading) } [Inject] private RegionHR.Web.Services.AuthService Auth { get; set; } = default!; + [Inject] private RegionHR.Web.Services.LedighetService Ledighet { get; set; } = default!; private bool _canApprove => Auth.EmployeeId.HasValue; private async Task Godkann(ApprovalItem item) { - await using var db = await DbFactory.CreateDbContextAsync(); if (!Auth.EmployeeId.HasValue) { _error = "Godkännande kräver kopplad anställd."; return; } + _error = null; try { if (item.EntityType == "LeaveRequest") { - var req = await db.LeaveRequests.FirstOrDefaultAsync(r => r.Id == item.Id); - if (req == null) return; - req.Godkann(Auth.EmployeeId.Value, null); - await db.SaveChangesAsync(); + // Samma väg som Ledighet/Index: tjänsten drar semestersaldo, + // avbokar överlappande schemapass och notifierar den anställde. + await Ledighet.GodkannAsync(item.Id, Auth.EmployeeId.Value, null); } _approvals.Remove(item); } @@ -196,16 +196,13 @@ else if (!_loading) private async Task Neka(ApprovalItem item) { - await using var db = await DbFactory.CreateDbContextAsync(); if (!Auth.EmployeeId.HasValue) { _error = "Avslag kräver kopplad anställd."; return; } + _error = null; try { if (item.EntityType == "LeaveRequest") { - var req = await db.LeaveRequests.FirstOrDefaultAsync(r => r.Id == item.Id); - if (req == null) return; - req.Avvisa(Auth.EmployeeId.Value, "Nekad av chef"); - await db.SaveChangesAsync(); + await Ledighet.AvslaAsync(item.Id, Auth.EmployeeId.Value, "Nekad av chef"); } _approvals.Remove(item); } @@ -217,8 +214,14 @@ else if (!_loading) await using var db = await DbFactory.CreateDbContextAsync(); try { - nudge.MarkeraSomLast(); - await db.SaveChangesAsync(); + // Hämta om entiteten i samma context som SaveChanges — den listade + // instansen kommer från init-contexten och trackas inte här. + var sparad = await db.CoachingNudges.FirstOrDefaultAsync(n => n.Id == nudge.Id); + if (sparad != null) + { + sparad.MarkeraSomLast(); + await db.SaveChangesAsync(); + } _nudges.Remove(nudge); } catch (Exception ex) { _error = ex.Message; } diff --git a/src/Web/Components/Pages/Dokument/MallGenerator.razor b/src/Web/Components/Pages/Dokument/MallGenerator.razor index b0c9bc9..31cc9b2 100644 --- a/src/Web/Components/Pages/Dokument/MallGenerator.razor +++ b/src/Web/Components/Pages/Dokument/MallGenerator.razor @@ -1,8 +1,14 @@ @page "/dokument/mall/generera" @layout RegionHR.Web.Components.Layout.AdminLayout +@using System.Globalization +@using Microsoft.EntityFrameworkCore @using RegionHR.Web.Components.Shared @using RegionHR.Infrastructure.Documents +@using RegionHR.Infrastructure.Export @inject DocumentTemplateEngine TemplateEngine +@inject PdfGenerator PdfGen +@inject RegionHR.Web.Services.AnstallningService AnstallningService +@inject IDbContextFactory DbFactory @inject IJSRuntime JS OpenHR - Generera dokument @@ -22,7 +28,13 @@ case 1: Fyll i uppgifter — @_mallNamn - Systemet har fyllt i det vi kan automatiskt. Komplettera resten. + Välj en anställd så fyller systemet i uppgifterna automatiskt ur personalregistret. Komplettera resten. + + @foreach (var a in _anstallda) + { + @a.Efternamn, @a.Fornamn — @a.Befattning + } + @foreach (var field in _fields) { @@ -35,11 +47,8 @@ @_pdfPreview - - PDF-generering kräver QuestPDF-paketet. Installera med: dotnet add package QuestPDF -
- Ladda ner + Ladda ner PDF Skriv ut Redigera
@@ -55,6 +64,24 @@ private Dictionary _fieldValues = new(); private string _preview = ""; private string _pdfPreview = ""; + private byte[] _pdfBytes = []; + + private List _anstallda = []; + private Guid? _valdAnstalldId; + private Employee? _valdAnstalld; + private Employment? _valdAnstallning; + + protected override async Task OnInitializedAsync() + { + try + { + _anstallda = await AnstallningService.HamtaAllaAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"MallGenerator load error: {ex.Message}"); + } + } private void ValjMall(string id, string namn) { @@ -62,62 +89,182 @@ _mallNamn = namn; _fields = TemplateEngine.GetFieldNames(id); _fieldValues = _fields.ToDictionary(f => f, f => GetDefaultValue(f)); + if (_valdAnstalldId is not null) + FyllFaltFranAnstalld(); _step = 1; } private string GetDefaultValue(string field) => field switch { - "Arbetsgivare" => "Västra Götalandsregionen", - "OrgNummer" => "232100-0131", + "Arbetsgivare" => PdfGenerator.ArbetsgivareNamn, + "OrgNummer" => PdfGenerator.ArbetsgivareOrgnr, "Datum" => DateTime.Today.ToString("yyyy-MM-dd"), "Kollektivavtal" => "AB", _ => "" }; + private async Task ValjAnstalld(Guid? id) + { + _valdAnstalldId = id; + _valdAnstalld = null; + _valdAnstallning = null; + if (id is null) return; + + try + { + _valdAnstalld = await AnstallningService.HamtaAsync(EmployeeId.From(id.Value)); + if (_valdAnstalld is null) return; + + var idag = DateOnly.FromDateTime(DateTime.Today); + _valdAnstallning = _valdAnstalld.AktivAnstallning(idag) + ?? _valdAnstalld.Anstallningar.OrderByDescending(a => a.Giltighetsperiod.Start).FirstOrDefault(); + + FyllFaltFranAnstalld(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"MallGenerator ValjAnstalld error: {ex.Message}"); + } + } + + /// Fyller mallens fält automatiskt ur personalregistret — användaren kompletterar bara det som saknas. + private void FyllFaltFranAnstalld() + { + if (_valdAnstalld is null) return; + + SattFalt("Fornamn", _valdAnstalld.Fornamn); + SattFalt("Efternamn", _valdAnstalld.Efternamn); + SattFalt("Personnummer", _valdAnstalld.Personnummer.ToMaskedString()); + + if (_valdAnstallning is null) return; + + SattFalt("Befattning", _valdAnstallning.Befattningstitel); + SattFalt("Enhet", HamtaEnhetsNamn(_valdAnstallning.EnhetId)); + SattFalt("Anstallningsform", _valdAnstallning.Anstallningsform.ToString()); + SattFalt("Sysselsattningsgrad", _valdAnstallning.Sysselsattningsgrad.Value.ToString("0.##", CultureInfo.InvariantCulture)); + SattFalt("Manadslon", _valdAnstallning.Manadslon.Amount.ToString("0", CultureInfo.InvariantCulture)); + SattFalt("Kollektivavtal", _valdAnstallning.Kollektivavtal.ToString()); + SattFalt("Startdatum", _valdAnstallning.Giltighetsperiod.Start.ToString("yyyy-MM-dd")); + SattFalt("Slutdatum", _valdAnstallning.Giltighetsperiod.End?.ToString("yyyy-MM-dd")); + SattFalt("TidigareLon", _valdAnstallning.Manadslon.Amount.ToString("0", CultureInfo.InvariantCulture)); + } + + private void SattFalt(string falt, string? varde) + { + if (varde is not null && _fieldValues.ContainsKey(falt)) + _fieldValues[falt] = varde; + } + + private string HamtaEnhetsNamn(OrganizationId enhetId) + { + try + { + using var db = DbFactory.CreateDbContext(); + return db.OrganizationUnits.AsEnumerable().FirstOrDefault(o => o.Id == enhetId)?.Namn ?? ""; + } + catch + { + return ""; + } + } + private void Generera() { _preview = TemplateEngine.Generate(_mallId, _fieldValues); - // Generate PDF preview using PdfGenerator - var pdfGen = new RegionHR.Infrastructure.Export.PdfGenerator(); - byte[] pdfBytes; - if (_mallId == "tjanstgoringsbevis") + if (_mallId == "anstallningsavtal") { - pdfBytes = pdfGen.GenerateTjanstgoringsintyg(new RegionHR.Infrastructure.Export.TjanstgoringsintyData( - _fieldValues.GetValueOrDefault("Namn", "—"), - _fieldValues.GetValueOrDefault("Personnummer", "—"), - _fieldValues.GetValueOrDefault("Arbetsgivare", "Västra Götalandsregionen"), - _fieldValues.GetValueOrDefault("Startdatum", "—"), - _fieldValues.GetValueOrDefault("Slutdatum", "—"), - _fieldValues.GetValueOrDefault("Befattning", "—"), - _fieldValues.GetValueOrDefault("Anställningsform", "Tillsvidare"), - _fieldValues.GetValueOrDefault("Sysselsättningsgrad", "100%") - )); + // LAS 6c §: bygg avtalet på den kompletta avtalsgeneratorn i Core + var uppgifter = ByggAvtalsUppgifter(); + _pdfBytes = PdfGen.GenerateAnstallningsavtal(uppgifter); + _pdfPreview = "ANSTÄLLNINGSAVTAL\nSkriftlig information enligt LAS 6 c §\n\n" + + AnstallningsavtalGenerator.Skapa6cText(uppgifter); } - else if (_mallId == "anstallningsavtal") + else if (_mallId == "tjanstgoringsbevis") { - _ = int.TryParse(_fieldValues.GetValueOrDefault("Sysselsättningsgrad", "100").Replace("%", ""), out var grad); - _ = decimal.TryParse(_fieldValues.GetValueOrDefault("Månadslön", "0").Replace(" ", ""), out var lon); - pdfBytes = pdfGen.GenerateAnstallningsavtal(new RegionHR.Infrastructure.Export.AnstallningsavtalData( - _fieldValues.GetValueOrDefault("Namn", "—"), - _fieldValues.GetValueOrDefault("Personnummer", "—"), - _fieldValues.GetValueOrDefault("Arbetsgivare", "Västra Götalandsregionen"), - _fieldValues.GetValueOrDefault("Befattning", "—"), - _fieldValues.GetValueOrDefault("Tillträdesdatum", DateTime.Today.ToString("yyyy-MM-dd")), - _fieldValues.GetValueOrDefault("Anställningsform", "Tillsvidare"), - grad == 0 ? 100 : grad, - lon, - _fieldValues.GetValueOrDefault("Kollektivavtal", "AB") - )); + _pdfBytes = PdfGen.GenerateTjanstgoringsintyg(new TjanstgoringsintyData( + FulltNamnFranFalt(), + Falt("Personnummer", "—"), + Falt("Arbetsgivare", PdfGenerator.ArbetsgivareNamn), + Falt("Startdatum", "—"), + Falt("Slutdatum", "—"), + Falt("Befattning", "—"), + _valdAnstallning?.Anstallningsform.ToString() ?? "Tillsvidare", + $"{Falt("Sysselsattningsgrad", "100")}%")); + _pdfPreview = _preview; } else { - pdfBytes = System.Text.Encoding.UTF8.GetBytes(_preview); + _pdfBytes = PdfGen.GenerateEnkeltDokument(_mallNamn, _preview); + _pdfPreview = _preview; } - _pdfPreview = System.Text.Encoding.UTF8.GetString(pdfBytes); _step = 2; } + private string Falt(string namn, string standard) => + _fieldValues.TryGetValue(namn, out var v) && !string.IsNullOrWhiteSpace(v) ? v : standard; + + private string VisaAnstalld(Guid? id) + { + if (id is null) return ""; + var a = _anstallda.FirstOrDefault(x => x.Id.Value == id.Value); + return a is null ? "" : $"{a.Efternamn}, {a.Fornamn}"; + } + + private string FulltNamnFranFalt() => + $"{Falt("Fornamn", "")} {Falt("Efternamn", "")}".Trim() is { Length: > 0 } namn ? namn : "—"; + + private AnstallningsavtalUppgifter ByggAvtalsUppgifter() + { + // Fältvärdet (ev. redigerat av användaren) har företräde; annars den valda anställningen. + var anstallningsform = Enum.TryParse(Falt("Anstallningsform", "").Trim(), true, out var form) + ? form + : _valdAnstallning?.Anstallningsform ?? EmploymentType.Tillsvidare; + var kollektivavtal = Enum.TryParse(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); + } +}