Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<!-- PDF Generation -->
<!-- PdfSharpCore behålls: PdfPayslipGenerator (MinLon-lönespecen) använder den fortfarande -->
<PackageVersion Include="PdfSharpCore" Version="1.3.65" />
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
<!-- Excel Export -->
<PackageVersion Include="ClosedXML" Version="0.104.2" />
<!-- Hosting -->
Expand Down
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,34 @@

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

### Beräkningsmotorer (kopplade till UI, korrekta 2026-värden)
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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
```

Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/Infrastructure/Analytics/BeslutsstodKpiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand Down
118 changes: 114 additions & 4 deletions src/Infrastructure/BackgroundJobs/CertificationReminderService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="LASAlertService"/>.
///
/// 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.
/// </summary>
public class CertificationReminderService : BackgroundService
{
/// <summary>Påminnelsetrösklar i dagar före utgång, i stigande ordning.</summary>
internal static readonly int[] TroskelDagar = [30, 60, 90];

private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<CertificationReminderService> _logger;
private readonly TimeSpan _interval = TimeSpan.FromHours(24);

public CertificationReminderService(ILogger<CertificationReminderService> logger)
public CertificationReminderService(IServiceScopeFactory scopeFactory, ILogger<CertificationReminderService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}

Expand All @@ -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<RegionHRDbContext>();
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);
}
}

/// <summary>
/// Kör en bevakningsomgång mot en given DbContext. Intern för testbarhet.
/// Returnerar antalet skapade notifieringar.
/// </summary>
internal async Task<int> 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<Guid> { 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)
};
}
Loading
Loading