diff --git a/src/Infrastructure/Persistence/Demo/DemoBefolkningsGenerator.cs b/src/Infrastructure/Persistence/Demo/DemoBefolkningsGenerator.cs new file mode 100644 index 0000000..2951974 --- /dev/null +++ b/src/Infrastructure/Persistence/Demo/DemoBefolkningsGenerator.cs @@ -0,0 +1,287 @@ +using System.Text; +using Microsoft.EntityFrameworkCore; +using RegionHR.Core.Domain; +using RegionHR.SharedKernel.Domain; + +namespace RegionHR.Infrastructure.Persistence.Demo; + +/// +/// Procedurell generator som befolkar demo-databasen med ~11 000 anställda fördelade +/// över Region Örebro läns organisationsträd (byggt av ). +/// +/// Designbeslut: +/// +/// Använder domänfabrikerna (Employee.Skapa + LaggTillAnstallning) +/// så att alla LAS-/arbetsrättsinvarianter valideras precis som i produktion. +/// Giltiga, unika personnummer via (beräknad +/// Luhn-siffra, könskonsekvent paritet). +/// Prestanda: AddRange + SaveChangesAsync i batchar (default 1 500), +/// med ChangeTracker.Clear() mellan batchar för att hålla minnet konstant. +/// Endast personal-/anställningsposter skapas för massan — inga scheman, +/// lönekörningar eller ledigheter. Domänevents nollställs per anställd innan spar +/// (ClearDomainEvents) så att den per-anställnings LAS-auto-kedjan och övriga +/// sidoeffekter inte triggas 11 000 gånger vid seed. De handplockade demo-användarna +/// ovanför i seeden behåller sina events och sin rika data helt orörda. +/// +/// +public static class DemoBefolkningsGenerator +{ + /// Målstorlek på den genererade demo-befolkningen. Justera fritt. + public const int DEMO_BEFOLKNING = 11_000; + + /// Antal anställda per SaveChanges-batch. + private const int BATCH = 1_500; + + /// + /// Bygger ut organisationsträdet och genererar anställda. + /// Anropas från SeedData.SeedAsync EFTER den handplockade demo-datan sparats. + /// + /// Kontext (redan sparad handplockad seed — säkert att clear:a change tracker). + /// Den redan seedade regionnoden (nya förvaltningar hängs här). + /// Den redan seedade USÖ-noden (nya kliniker hängs här). + /// DB-id för AB-kollektivavtalet som anställningarna kopplas till. + /// Personnummer som redan används (handplockade demo-användare). + /// Antal att generera (default ). + /// Slumpkälla (injiceras för deterministiska tester). + public static async Task GenereraAsync( + RegionHRDbContext db, + OrganizationId regionId, + OrganizationId usoId, + CollectiveAgreementId? abAvtalsId, + IEnumerable? reserveradePersonnummer = null, + int antal = DEMO_BEFOLKNING, + Random? rng = null) + { + rng ??= new Random(); + var idag = DateOnly.FromDateTime(DateTime.Today); + + // 1) Bygg och spara det utökade organisationsträdet (en SaveChanges). + var (enheter, bemanningsbara) = DemoOrganisation.Bygg(regionId, usoId); + db.OrganizationUnits.AddRange(enheter); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + if (antal <= 0 || bemanningsbara.Count == 0) return; + + // 2) Fördela antalet över enheterna enligt relativ storleksvikt. + var fordelning = BeraknaFordelning(bemanningsbara, antal); + + var pnrGen = new PersonnummerGenerator(rng, reserveradePersonnummer); + var anvandaEpost = new HashSet(StringComparer.OrdinalIgnoreCase); + var batch = new List(BATCH); + var lopnummer = 0; + + for (var ei = 0; ei < bemanningsbara.Count; ei++) + { + var enhet = bemanningsbara[ei]; + var antalPaEnhet = fordelning[ei]; + + for (var k = 0; k < antalPaEnhet; k++) + { + batch.Add(SkapaAnstalld(enhet, ++lopnummer, pnrGen, anvandaEpost, abAvtalsId, rng, idag)); + + if (batch.Count >= BATCH) + await SparaBatchAsync(db, batch); + } + } + + if (batch.Count > 0) + await SparaBatchAsync(db, batch); + } + + private static async Task SparaBatchAsync(RegionHRDbContext db, List batch) + { + db.Employees.AddRange(batch); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + batch.Clear(); + } + + /// + /// Skapar EN anställd med giltigt personnummer, kontakt-/skatteuppgifter och en + /// anställning (yrke, lön, sysselsättningsgrad, anställningsform) via domänfabrikerna. + /// Domänevents nollställs innan retur så att bulk-seeden inte triggar sidoeffekter. + /// + private static Employee SkapaAnstalld( + DemoEnhet enhet, int lopnummer, PersonnummerGenerator pnrGen, + HashSet anvandaEpost, CollectiveAgreementId? abAvtalsId, Random rng, DateOnly idag) + { + var arKvinna = rng.NextDouble() < 0.72; // regionen är kvinnodominerad (~72 % kvinnor) + var fornamn = arKvinna + ? SvenskaNamn.Kvinnonamn[rng.Next(SvenskaNamn.Kvinnonamn.Length)] + : SvenskaNamn.Mansnamn[rng.Next(SvenskaNamn.Mansnamn.Length)]; + var efternamn = SvenskaNamn.Efternamn[rng.Next(SvenskaNamn.Efternamn.Length)]; + + var alder = Alder(rng); + var pnr = pnrGen.NastaUnikt(alder, arKvinna, idag); + + var employee = Employee.Skapa(pnr, fornamn, efternamn); + employee.UppdateraKontaktuppgifter( + UnikEpost(fornamn, efternamn, lopnummer, anvandaEpost), + $"070-{rng.Next(100, 1000):D3} {rng.Next(0, 100):D2} {rng.Next(0, 100):D2}", + null); + // Örebro → Skatteverkets tabell 34, kolumn 1 (samma som handplockad seed). + employee.UppdateraSkatteuppgifter(34, 1, "Örebro", 33.65m, harKyrkoavgift: false, kyrkoavgiftssats: null); + + var yrke = ValjYrke(enhet.Profil, rng); + var lon = Money.SEK(Math.Round(rng.Next(yrke.LonMin, yrke.LonMax + 1) / 100m) * 100m); + var (form, grad, start, slut) = Anstallningsvillkor(rng, pnr.BirthDate, idag); + + employee.LaggTillAnstallning( + enhet.Id, form, CollectiveAgreementType.AB, lon, grad, start, slut, + aidKod: yrke.AidKod, befattningstitel: yrke.Titel, avtalsId: abAvtalsId); + + // Nollställ domänevents → ingen per-anställnings LAS-auto-kedja/dispatch vid bulk-seed. + employee.ClearDomainEvents(); + return employee; + } + + /// + /// Fördelar platser över enheterna proportionellt mot vikt. + /// Summan av returvektorn är EXAKT (största-rest-metoden). + /// Ren funktion — testbar utan databas. + /// + public static int[] BeraknaFordelning(IReadOnlyList enheter, int antal) + { + var resultat = new int[enheter.Count]; + if (enheter.Count == 0 || antal <= 0) return resultat; + + long totalVikt = enheter.Sum(e => (long)e.Vikt); + if (totalVikt <= 0) + { + // Ingen vikt → jämn fördelning. + for (var i = 0; i < enheter.Count; i++) + resultat[i] = antal / enheter.Count + (i < antal % enheter.Count ? 1 : 0); + return resultat; + } + + var rester = new (int Index, double Brakdel)[enheter.Count]; + var tilldelat = 0; + for (var i = 0; i < enheter.Count; i++) + { + var exakt = (double)enheter[i].Vikt / totalVikt * antal; + var golv = (int)Math.Floor(exakt); + resultat[i] = golv; + tilldelat += golv; + rester[i] = (i, exakt - golv); + } + + // Dela ut återstoden till enheterna med störst bråkdel. + var kvar = antal - tilldelat; + foreach (var (index, _) in rester.OrderByDescending(r => r.Brakdel).Take(Math.Max(0, kvar))) + resultat[index]++; + + return resultat; + } + + /// Åldersfördelning 20–67 med central tyngdpunkt (~44) via medel av två uniforma dragningar. + private static int Alder(Random rng) => (rng.Next(20, 68) + rng.Next(20, 68)) / 2; + + /// Väljer ett yrke ur profilen med viktad slump. + private static Yrke ValjYrke(EnhetsProfil profil, Random rng) + { + var total = profil.Yrken.Sum(y => y.Andel); + var traff = rng.Next(0, total); + var ackumulerat = 0; + foreach (var ya in profil.Yrken) + { + ackumulerat += ya.Andel; + if (traff < ackumulerat) return ya.Yrke; + } + return profil.Yrken[^1].Yrke; + } + + /// + /// Bestämmer anställningsform, sysselsättningsgrad och giltighetsperiod inom + /// arbetsrättens ramar (LAS): mest tillsvidare (~85 %), en andel visstid som alltid + /// får ett slutdatum, och provanställning kapad till max 6 månader. Startdatum + /// sprids bakåt men aldrig före 18 års ålder eller in i framtiden. + /// + private static (EmploymentType Form, Percentage Grad, DateOnly Start, DateOnly? Slut) Anstallningsvillkor( + Random rng, DateOnly fodelsedatum, DateOnly idag) + { + // Tidigast möjliga anställningsstart: 18-årsdagen (aldrig i framtiden givet ålder ≥ 20). + var tidigast = fodelsedatum.AddYears(18); + if (tidigast > idag) tidigast = idag.AddYears(-1); + var maxBakatDagar = Math.Max(1, idag.DayNumber - tidigast.DayNumber); + + var g = rng.NextDouble(); + var gradVarde = g < 0.74 ? 100m : g < 0.82 ? 75m : g < 0.88 ? 80m + : g < 0.93 ? 50m : g < 0.96 ? 90m : g < 0.98 ? 60m : 25m; + var grad = new Percentage(gradVarde); + + var r = rng.NextDouble(); + EmploymentType form; + DateOnly start; + DateOnly? slut = null; + + if (r < 0.85) + { + // Tillsvidare — spridd upp till 30 år bakåt (kapat av 18-årsgränsen). + form = EmploymentType.Tillsvidare; + var spann = Math.Min(maxBakatDagar, 30 * 365); + start = idag.AddDays(-rng.Next(0, spann + 1)); + } + else if (r < 0.93) + { + form = EmploymentType.Vikariat; + start = SenareStart(rng, tidigast, idag, 3 * 365); + slut = start.AddMonths(rng.Next(3, 19)); + } + else if (r < 0.97) + { + form = EmploymentType.SAVA; + start = SenareStart(rng, tidigast, idag, 2 * 365); + slut = start.AddMonths(rng.Next(3, 13)); + } + else + { + form = EmploymentType.Provanstallning; + start = SenareStart(rng, tidigast, idag, 150); + slut = start.AddMonths(Employment.MaxProvanstallningManader); // exakt 6 mån (LAS 6 §) + } + + return (form, grad, start, slut); + } + + /// Ett relativt sent startdatum (för visstid), aldrig före 18-årsgränsen. + private static DateOnly SenareStart(Random rng, DateOnly tidigast, DateOnly idag, int maxDagarBakat) + { + var spann = Math.Min(Math.Max(1, idag.DayNumber - tidigast.DayNumber), maxDagarBakat); + var start = idag.AddDays(-rng.Next(0, spann + 1)); + return start < tidigast ? tidigast : start; + } + + /// Bygger en e-postadress och gör den unik med löpnummer-suffix vid krock. + private static string UnikEpost(string fornamn, string efternamn, int lopnummer, HashSet anvanda) + { + var bas = $"{Translitterera(fornamn)}.{Translitterera(efternamn)}"; + var epost = $"{bas}@regionorebrolan.se"; + if (!anvanda.Add(epost)) + { + epost = $"{bas}{lopnummer}@regionorebrolan.se"; + anvanda.Add(epost); + } + return epost; + } + + /// Translittererar svenska tecken till a–z för e-postlokaldelar. + private static string Translitterera(string s) + { + var sb = new StringBuilder(s.Length); + foreach (var c in s.ToLowerInvariant()) + { + sb.Append(c switch + { + 'å' or 'ä' or 'á' or 'à' => "a", + 'ö' or 'ø' or 'ó' => "o", + 'é' or 'è' or 'ê' => "e", + 'ü' or 'ú' => "u", + 'æ' => "ae", + ' ' or '-' or '\'' => "", + _ => char.IsLetterOrDigit(c) ? c.ToString() : "" + }); + } + return sb.ToString(); + } +} diff --git a/src/Infrastructure/Persistence/Demo/DemoOrganisation.cs b/src/Infrastructure/Persistence/Demo/DemoOrganisation.cs new file mode 100644 index 0000000..0b04ead --- /dev/null +++ b/src/Infrastructure/Persistence/Demo/DemoOrganisation.cs @@ -0,0 +1,417 @@ +using RegionHR.Core.Domain; +using RegionHR.SharedKernel.Domain; + +namespace RegionHR.Infrastructure.Persistence.Demo; + +/// Ett yrke med lönespann (SEK/mån, 2026 års nivå) och AID-kod för statistik. +public sealed record Yrke(string Titel, int LonMin, int LonMax, string? AidKod = null); + +/// Ett yrke med en relativ andel (vikt) inom en enhetsprofil. +public sealed record YrkeAndel(Yrke Yrke, int Andel); + +/// Yrkessammansättningen för en enhetstyp (t.ex. sjukhusavdelning, vårdcentral). +public sealed record EnhetsProfil(string Namn, IReadOnlyList Yrken); + +/// En bemanningsbar enhet: id + profil + relativ storleksvikt. +public sealed record DemoEnhet(OrganizationId Id, string Namn, EnhetsProfil Profil, int Vikt); + +/// +/// Katalog över yrken som förekommer i Region Örebro län, med realistiska +/// månadslönespann (2026) och AID-koder. Löner slås upp som ett intervall och +/// randomiseras per anställd i generatorn. +/// +public static class Yrken +{ + public static readonly Yrke Undersköterska = new("Undersköterska", 26000, 32000, "206011"); + public static readonly Yrke Skötare = new("Skötare", 27000, 33000, "206013"); + public static readonly Yrke Sjuksköterska = new("Sjuksköterska", 33000, 41000, "204012"); + public static readonly Yrke Specialistsjuksköterska = new("Specialistsjuksköterska", 39000, 47000, "203011"); + public static readonly Yrke Barnmorska = new("Barnmorska", 39000, 47000, "202011"); + public static readonly Yrke Distriktssköterska = new("Distriktssköterska", 37000, 45000, "203012"); + public static readonly Yrke Röntgensjuksköterska = new("Röntgensjuksköterska", 34000, 42000, "204013"); + public static readonly Yrke Överläkare = new("Överläkare", 75000, 115000, "101012"); + public static readonly Yrke STläkare = new("ST-läkare", 48000, 58000, "102012"); + public static readonly Yrke Underläkare = new("Underläkare", 40000, 47000, "102011"); + public static readonly Yrke Distriktsläkare = new("Distriktsläkare", 78000, 105000, "101013"); + public static readonly Yrke BiomedicinskAnalytiker = new("Biomedicinsk analytiker", 33000, 41000, "205011"); + public static readonly Yrke Tandläkare = new("Tandläkare", 46000, 68000, "301011"); + public static readonly Yrke Tandhygienist = new("Tandhygienist", 33000, 41000, "302011"); + public static readonly Yrke Tandsköterska = new("Tandsköterska", 27000, 33000, "303011"); + public static readonly Yrke Fysioterapeut = new("Fysioterapeut", 33000, 41000, "207011"); + public static readonly Yrke Arbetsterapeut = new("Arbetsterapeut", 33000, 40000, "208011"); + public static readonly Yrke Psykolog = new("Psykolog", 41000, 53000, "209011"); + public static readonly Yrke Kurator = new("Kurator", 35000, 43000, "210011"); + public static readonly Yrke Dietist = new("Dietist", 34000, 42000, "211011"); + public static readonly Yrke MedicinskSekreterare = new("Medicinsk sekreterare", 29000, 35000, "401011"); + public static readonly Yrke Vårdadministratör = new("Vårdadministratör", 28000, 34000, "401012"); + public static readonly Yrke HRspecialist = new("HR-specialist", 37000, 49000, "502011"); + public static readonly Yrke Ekonom = new("Ekonom", 37000, 51000, "503011"); + public static readonly Yrke ITtekniker = new("IT-tekniker", 36000, 48000, "504011"); + public static readonly Yrke Systemförvaltare = new("Systemförvaltare", 42000, 56000, "504012"); + public static readonly Yrke Inköpare = new("Inköpare", 39000, 51000, "505011"); + public static readonly Yrke Administratör = new("Administratör", 28000, 36000, "501011"); + public static readonly Yrke Handläggare = new("Handläggare", 34000, 46000, "501012"); + public static readonly Yrke Kommunikatör = new("Kommunikatör", 35000, 45000, "506011"); + public static readonly Yrke Verksamhetschef = new("Verksamhetschef", 62000, 95000, "601011"); + public static readonly Yrke Enhetschef = new("Enhetschef", 49000, 69000, "602011"); + public static readonly Yrke Avdelningschef = new("Avdelningschef", 52000, 74000, "602012"); + public static readonly Yrke Trafikplanerare = new("Trafikplanerare", 34000, 46000, "701011"); + public static readonly Yrke Bibliotekarie = new("Bibliotekarie", 32000, 41000, "801011"); + public static readonly Yrke Kulturhandläggare = new("Kulturhandläggare", 35000, 45000, "802011"); + public static readonly Yrke Lärare = new("Lärare", 34000, 45000, "803011"); + public static readonly Yrke Fastighetsskötare = new("Fastighetsskötare", 28000, 35000, "702011"); + public static readonly Yrke Lokalvårdare = new("Lokalvårdare", 25000, 30000, "703011"); + public static readonly Yrke Måltidsbiträde = new("Måltidsbiträde", 25000, 30000, "704011"); + public static readonly Yrke Kock = new("Kock", 27000, 34000, "704012"); +} + +/// Fördefinierade yrkessammansättningar per enhetstyp. +public static class Profiler +{ + private static YrkeAndel A(Yrke y, int andel) => new(y, andel); + + /// Somatisk vårdavdelning/klinik (medicin, kirurgi, ortopedi m.fl.). + public static readonly EnhetsProfil Sjukhusvård = new("Sjukhusvård", + [ + A(Yrken.Sjuksköterska, 34), A(Yrken.Undersköterska, 28), A(Yrken.Specialistsjuksköterska, 7), + A(Yrken.Överläkare, 7), A(Yrken.STläkare, 5), A(Yrken.Underläkare, 3), + A(Yrken.MedicinskSekreterare, 6), A(Yrken.Fysioterapeut, 3), A(Yrken.Arbetsterapeut, 2), + A(Yrken.Vårdadministratör, 2), A(Yrken.Enhetschef, 2), A(Yrken.Avdelningschef, 1) + ]); + + /// Anestesi/IVA/operation/kirurgiska specialiteter (specialistsjuksköterske-tungt). + public static readonly EnhetsProfil IntensivOperation = new("Intensiv/Operation", + [ + A(Yrken.Specialistsjuksköterska, 22), A(Yrken.Sjuksköterska, 18), A(Yrken.Undersköterska, 22), + A(Yrken.Överläkare, 12), A(Yrken.STläkare, 6), A(Yrken.Underläkare, 2), + A(Yrken.MedicinskSekreterare, 4), A(Yrken.Enhetschef, 2) + ]); + + /// Akutmottagning/akutklinik. + public static readonly EnhetsProfil Akutvård = new("Akutvård", + [ + A(Yrken.Sjuksköterska, 32), A(Yrken.Undersköterska, 26), A(Yrken.Specialistsjuksköterska, 8), + A(Yrken.Överläkare, 8), A(Yrken.STläkare, 6), A(Yrken.Underläkare, 5), + A(Yrken.MedicinskSekreterare, 6), A(Yrken.Enhetschef, 2) + ]); + + /// Kvinnoklinik/förlossning (barnmorske-tungt). + public static readonly EnhetsProfil KvinnaBarn = new("Kvinna/Barn", + [ + A(Yrken.Barnmorska, 30), A(Yrken.Sjuksköterska, 18), A(Yrken.Undersköterska, 20), + A(Yrken.Överläkare, 10), A(Yrken.STläkare, 6), A(Yrken.Underläkare, 3), + A(Yrken.MedicinskSekreterare, 6), A(Yrken.Enhetschef, 2) + ]); + + /// Laboratoriemedicin (BMA-tungt). + public static readonly EnhetsProfil Laboratorium = new("Laboratorium", + [ + A(Yrken.BiomedicinskAnalytiker, 55), A(Yrken.Undersköterska, 15), A(Yrken.Överläkare, 8), + A(Yrken.STläkare, 4), A(Yrken.Sjuksköterska, 6), A(Yrken.MedicinskSekreterare, 6), + A(Yrken.Enhetschef, 2), A(Yrken.Avdelningschef, 1) + ]); + + /// Radiologi/klinisk fysiologi (röntgensjuksköterske-tungt). + public static readonly EnhetsProfil Radiologi = new("Radiologi", + [ + A(Yrken.Röntgensjuksköterska, 45), A(Yrken.Undersköterska, 12), A(Yrken.Överläkare, 12), + A(Yrken.STläkare, 6), A(Yrken.BiomedicinskAnalytiker, 5), A(Yrken.MedicinskSekreterare, 8), + A(Yrken.Enhetschef, 2) + ]); + + /// Psykiatri (allmän/rätts/BUP). + public static readonly EnhetsProfil Psykiatri = new("Psykiatri", + [ + A(Yrken.Skötare, 30), A(Yrken.Sjuksköterska, 24), A(Yrken.Undersköterska, 8), + A(Yrken.Psykolog, 12), A(Yrken.Överläkare, 8), A(Yrken.STläkare, 4), + A(Yrken.Kurator, 8), A(Yrken.MedicinskSekreterare, 4), A(Yrken.Enhetschef, 2) + ]); + + /// Ambulanssjukvård. + public static readonly EnhetsProfil Ambulans = new("Ambulans", + [ + A(Yrken.Sjuksköterska, 40), A(Yrken.Specialistsjuksköterska, 25), A(Yrken.Undersköterska, 25), + A(Yrken.Överläkare, 3), A(Yrken.Enhetschef, 2) + ]); + + /// Habilitering och hjälpmedel. + public static readonly EnhetsProfil Habilitering = new("Habilitering", + [ + A(Yrken.Fysioterapeut, 18), A(Yrken.Arbetsterapeut, 16), A(Yrken.Psykolog, 14), + A(Yrken.Kurator, 12), A(Yrken.Sjuksköterska, 10), A(Yrken.Handläggare, 8), + A(Yrken.Administratör, 12), A(Yrken.Dietist, 4), A(Yrken.Enhetschef, 4), A(Yrken.Överläkare, 2) + ]); + + /// Vårdcentral/primärvård. + public static readonly EnhetsProfil Primärvård = new("Primärvård", + [ + A(Yrken.Distriktssköterska, 15), A(Yrken.Sjuksköterska, 16), A(Yrken.Undersköterska, 18), + A(Yrken.Distriktsläkare, 12), A(Yrken.STläkare, 4), A(Yrken.Fysioterapeut, 8), + A(Yrken.Arbetsterapeut, 5), A(Yrken.Psykolog, 4), A(Yrken.Kurator, 4), A(Yrken.Dietist, 2), + A(Yrken.Vårdadministratör, 10), A(Yrken.MedicinskSekreterare, 4), A(Yrken.Enhetschef, 2) + ]); + + /// Folktandvårdsklinik. + public static readonly EnhetsProfil Folktandvård = new("Folktandvård", + [ + A(Yrken.Tandsköterska, 44), A(Yrken.Tandläkare, 26), A(Yrken.Tandhygienist, 20), + A(Yrken.Administratör, 7), A(Yrken.Enhetschef, 3) + ]); + + /// Administration/service (kansli, HR, ekonomi, IT, inköp). + public static readonly EnhetsProfil AdminService = new("Admin/Service", + [ + A(Yrken.Administratör, 24), A(Yrken.ITtekniker, 14), A(Yrken.Systemförvaltare, 8), + A(Yrken.HRspecialist, 14), A(Yrken.Ekonom, 14), A(Yrken.Inköpare, 8), + A(Yrken.Kommunikatör, 8), A(Yrken.Enhetschef, 6), A(Yrken.Verksamhetschef, 4) + ]); + + /// Regionfastigheter (drift/lokalvård). + public static readonly EnhetsProfil Fastighet = new("Fastighet", + [ + A(Yrken.Fastighetsskötare, 42), A(Yrken.Lokalvårdare, 26), A(Yrken.Administratör, 12), + A(Yrken.Enhetschef, 5), A(Yrken.Ekonom, 5), A(Yrken.Inköpare, 5), + A(Yrken.Kommunikatör, 2), A(Yrken.Verksamhetschef, 3) + ]); + + /// Måltidsservice/kost. + public static readonly EnhetsProfil Måltid = new("Måltid", + [ + A(Yrken.Måltidsbiträde, 50), A(Yrken.Kock, 26), A(Yrken.Administratör, 10), + A(Yrken.Enhetschef, 6), A(Yrken.Inköpare, 4), A(Yrken.Verksamhetschef, 4) + ]); + + /// Regional utveckling (näringsliv, kompetens, infrastruktur, folkhälsa, energi). + public static readonly EnhetsProfil RegionalUtveckling = new("Regional utveckling", + [ + A(Yrken.Handläggare, 34), A(Yrken.Administratör, 18), A(Yrken.Kommunikatör, 12), + A(Yrken.Ekonom, 10), A(Yrken.Enhetschef, 8), A(Yrken.Verksamhetschef, 4), A(Yrken.ITtekniker, 4) + ]); + + /// Kultur och bildning (bibliotek, scenkonst, museum, kulturenhet). + public static readonly EnhetsProfil Kultur = new("Kultur", + [ + A(Yrken.Kulturhandläggare, 20), A(Yrken.Bibliotekarie, 22), A(Yrken.Administratör, 18), + A(Yrken.Handläggare, 9), A(Yrken.Kommunikatör, 10), A(Yrken.Enhetschef, 6), A(Yrken.Verksamhetschef, 3) + ]); + + /// Folkhögskola. + public static readonly EnhetsProfil Folkhögskola = new("Folkhögskola", + [ + A(Yrken.Lärare, 55), A(Yrken.Administratör, 22), A(Yrken.Enhetschef, 6), + A(Yrken.Lokalvårdare, 8), A(Yrken.Kommunikatör, 4), A(Yrken.Verksamhetschef, 5) + ]); + + /// Kollektivtrafik/Länstrafiken (planering och administration; drift upphandlas). + public static readonly EnhetsProfil Kollektivtrafik = new("Kollektivtrafik", + [ + A(Yrken.Trafikplanerare, 30), A(Yrken.Administratör, 26), A(Yrken.Kommunikatör, 14), + A(Yrken.Ekonom, 10), A(Yrken.Handläggare, 10), A(Yrken.Enhetschef, 6), A(Yrken.Verksamhetschef, 4) + ]); +} + +/// +/// Bygger ut Region Örebro läns organisationsträd realistiskt utifrån den faktiska +/// organisationen: förvaltningar → sjukhus (USÖ, Karlskoga, Lindesberg) med kliniker, +/// vårdcentraler, folktandvårdskliniker, service-, kultur- och kollektivtrafikenheter. +/// +/// De befintliga demo-enheterna (Region Örebro län + Universitetssjukhuset Örebro med +/// Avd 32/33, Akuten, IVA) återanvänds som ankare: USÖ:s kliniker hängs som syskon till +/// avdelningarna under det redan seedade USÖ-noden, och de nya förvaltningarna hängs +/// under den redan seedade regionnoden. Inga befintliga enheter muteras. +/// +public static class DemoOrganisation +{ + private static readonly DateOnly GiltigFran = new(2006, 1, 1); + + public static (List Enheter, List Bemanningsbara) Bygg( + OrganizationId regionId, OrganizationId usoId) + { + var enheter = new List(); + var bemanningsbara = new List(); + var ks = 21000; // Kostnadsställe-räknare — startar över de redan använda (10000/20000/2003x/2005x/2006x) + + OrganizationUnit Skapa(string namn, OrganizationUnitType typ, OrganizationId parent, + EnhetsProfil? profil = null, int vikt = 0) + { + var enhet = OrganizationUnit.Skapa(namn, typ, (ks++).ToString(), GiltigFran, overordnadEnhetId: parent); + enheter.Add(enhet); + if (profil is not null && vikt > 0) + bemanningsbara.Add(new DemoEnhet(enhet.Id, namn, profil, vikt)); + return enhet; + } + + void SkapaFlera(OrganizationId parent, OrganizationUnitType typ, + IEnumerable<(string Namn, EnhetsProfil Profil, int Vikt)> barn) + { + foreach (var (namn, profil, vikt) in barn) + Skapa(namn, typ, parent, profil, vikt); + } + + // === USÖ-kliniker: hängs under den redan seedade USÖ-noden (syskon till Avd 32/33/Akuten/IVA) === + SkapaFlera(usoId, OrganizationUnitType.Avdelning, new (string, EnhetsProfil, int)[] + { + ("Akutkliniken", Profiler.Akutvård, 190), + ("Anestesi- och intensivvårdskliniken", Profiler.IntensivOperation, 210), + ("Arbets- och miljömedicinska kliniken", Profiler.Sjukhusvård, 45), + ("Barn- och ungdomskliniken", Profiler.Sjukhusvård, 150), + ("Geriatriska kliniken", Profiler.Sjukhusvård, 130), + ("Handkirurgiska kliniken", Profiler.IntensivOperation, 70), + ("Hudkliniken", Profiler.Sjukhusvård, 55), + ("Infektionskliniken", Profiler.Sjukhusvård, 110), + ("Kirurgiska kliniken", Profiler.IntensivOperation, 220), + ("Kvinnokliniken", Profiler.KvinnaBarn, 200), + ("Kärl-thoraxkliniken", Profiler.IntensivOperation, 130), + ("Medicinska kliniken", Profiler.Sjukhusvård, 240), + ("Neuro- och rehabiliteringsmedicinska kliniken", Profiler.Sjukhusvård, 140), + ("Njurmedicinska kliniken", Profiler.Sjukhusvård, 90), + ("Onkologiska kliniken", Profiler.Sjukhusvård, 150), + ("Ortopediska kliniken", Profiler.IntensivOperation, 180), + ("Plastik- och käkkirurgiska kliniken", Profiler.IntensivOperation, 90), + ("Reumatologiska kliniken", Profiler.Sjukhusvård, 70), + ("Urologiska kliniken", Profiler.IntensivOperation, 90), + ("Ögonkliniken", Profiler.Sjukhusvård, 90), + ("Öron-, näs- och halskliniken", Profiler.Sjukhusvård, 100), + ("Klinisk fysiologi och nuklearmedicin", Profiler.Radiologi, 90), + ("Röntgenkliniken", Profiler.Radiologi, 190), + ("Laboratoriemedicinska länskliniken", Profiler.Laboratorium, 260), + ("Ambulanssjukvården", Profiler.Ambulans, 200), + ("Allmänpsykiatriska kliniken", Profiler.Psykiatri, 220), + ("Rättspsykiatriska kliniken", Profiler.Psykiatri, 150), + ("Barn- och ungdomspsykiatriska kliniken (BUP)", Profiler.Psykiatri, 120), + }); + + // === Hälso- och sjukvårdsförvaltningen → Karlskoga + Lindesberg + Habilitering === + var hsf = Skapa("Hälso- och sjukvårdsförvaltningen", OrganizationUnitType.Forvaltning, regionId); + + var karlskoga = Skapa("Karlskoga lasarett", OrganizationUnitType.Verksamhet, hsf.Id); + SkapaFlera(karlskoga.Id, OrganizationUnitType.Avdelning, new (string, EnhetsProfil, int)[] + { + ("Akutkliniken Karlskoga", Profiler.Akutvård, 90), + ("Medicinkliniken Karlskoga", Profiler.Sjukhusvård, 140), + ("Kirurgkliniken Karlskoga", Profiler.IntensivOperation, 110), + ("Ortopedkliniken Karlskoga", Profiler.IntensivOperation, 90), + ("Anestesi- och intensivvårdskliniken Karlskoga", Profiler.IntensivOperation, 100), + ("Röntgenkliniken Karlskoga", Profiler.Radiologi, 70), + ("Specialistmottagningar Karlskoga", Profiler.Sjukhusvård, 80), + }); + + var lindesberg = Skapa("Lindesbergs lasarett", OrganizationUnitType.Verksamhet, hsf.Id); + SkapaFlera(lindesberg.Id, OrganizationUnitType.Avdelning, new (string, EnhetsProfil, int)[] + { + ("Akutkliniken Lindesberg", Profiler.Akutvård, 70), + ("Kliniken för medicin och geriatrik Lindesberg", Profiler.Sjukhusvård, 120), + ("Kirurgiska kliniken Lindesberg", Profiler.IntensivOperation, 90), + ("Ortopedkliniken Lindesberg", Profiler.IntensivOperation, 80), + ("Anestesi- och intensivvårdskliniken Lindesberg", Profiler.IntensivOperation, 80), + ("Röntgenkliniken Lindesberg", Profiler.Radiologi, 55), + ("Specialistmottagningar Lindesberg", Profiler.Sjukhusvård, 70), + }); + + Skapa("Habilitering och hjälpmedel", OrganizationUnitType.Enhet, hsf.Id, Profiler.Habilitering, 220); + + // === Område Nära vård / Primärvård → alla vårdcentraler + jourcentraler + ungdomsmottagningar === + var narvard = Skapa("Område Nära vård / Primärvård", OrganizationUnitType.Forvaltning, regionId); + SkapaFlera(narvard.Id, OrganizationUnitType.Enhet, new (string, EnhetsProfil, int)[] + { + ("Adolfsbergs vårdcentral", Profiler.Primärvård, 60), + ("Brickebackens vårdcentral", Profiler.Primärvård, 55), + ("Karla vårdcentral", Profiler.Primärvård, 55), + ("Lillåns vårdcentral", Profiler.Primärvård, 50), + ("Mikaeli vårdcentral", Profiler.Primärvård, 60), + ("Odensbackens vårdcentral", Profiler.Primärvård, 40), + ("Olaus Petri vårdcentral", Profiler.Primärvård, 60), + ("Skebäcks vårdcentral", Profiler.Primärvård, 55), + ("Tybble vårdcentral", Profiler.Primärvård, 60), + ("Varberga vårdcentral", Profiler.Primärvård, 55), + ("Ängens vårdcentral", Profiler.Primärvård, 55), + ("Askersunds vårdcentral", Profiler.Primärvård, 45), + ("Hallsbergs vårdcentral", Profiler.Primärvård, 50), + ("Kumla vårdcentral", Profiler.Primärvård, 60), + ("Baggängens vårdcentral", Profiler.Primärvård, 50), + ("Brickegårdens vårdcentral", Profiler.Primärvård, 50), + ("Karolina vårdcentral", Profiler.Primärvård, 55), + ("Pilgårdens vårdcentral", Profiler.Primärvård, 40), + ("Laxå vårdcentral", Profiler.Primärvård, 35), + ("Lindesbergs vårdcentral", Profiler.Primärvård, 55), + ("Storå vårdcentral", Profiler.Primärvård, 30), + ("Freja vårdcentral", Profiler.Primärvård, 40), + ("Nora vårdcentral", Profiler.Primärvård, 45), + ("Hällefors vårdcentral", Profiler.Primärvård, 40), + ("Kopparbergs vårdcentral", Profiler.Primärvård, 35), + ("Vårdcentralsjouren Örebro", Profiler.Primärvård, 25), + ("Vårdcentralsjouren södra länsdelen", Profiler.Primärvård, 20), + ("Vårdcentralsjouren västra länsdelen", Profiler.Primärvård, 20), + ("Vårdcentralsjouren norra länsdelen", Profiler.Primärvård, 20), + ("Ungdomsmottagningar i länet", Profiler.Primärvård, 25), + }); + + // === Folktandvården → alla folktandvårdskliniker + specialisttandvård === + var ftv = Skapa("Folktandvården", OrganizationUnitType.Forvaltning, regionId); + SkapaFlera(ftv.Id, OrganizationUnitType.Enhet, new (string, EnhetsProfil, int)[] + { + ("Folktandvården Adolfsberg", Profiler.Folktandvård, 30), + ("Folktandvården Brickebacken", Profiler.Folktandvård, 26), + ("Folktandvården Eyra", Profiler.Folktandvård, 28), + ("Folktandvården Haga", Profiler.Folktandvård, 30), + ("Folktandvården Hertig Karl", Profiler.Folktandvård, 30), + ("Folktandvården Lillån", Profiler.Folktandvård, 24), + ("Folktandvården Sofia", Profiler.Folktandvård, 26), + ("Folktandvården Wivallius", Profiler.Folktandvård, 26), + ("Folktandvården Odensbacken", Profiler.Folktandvård, 18), + ("Folktandvården Kumla", Profiler.Folktandvård, 28), + ("Folktandvården Hallsberg", Profiler.Folktandvård, 24), + ("Folktandvården Askersund", Profiler.Folktandvård, 20), + ("Folktandvården Laxå", Profiler.Folktandvård, 16), + ("Folktandvården Lekeberg", Profiler.Folktandvård, 18), + ("Folktandvården Karlskoga", Profiler.Folktandvård, 32), + ("Folktandvården Degerfors", Profiler.Folktandvård, 20), + ("Folktandvården Lindesberg", Profiler.Folktandvård, 28), + ("Folktandvården Frövi", Profiler.Folktandvård, 18), + ("Folktandvården Nora", Profiler.Folktandvård, 22), + ("Folktandvården Hällefors", Profiler.Folktandvård, 18), + ("Folktandvården Kopparberg", Profiler.Folktandvård, 16), + ("Folktandvården Jourklinik", Profiler.Folktandvård, 20), + ("Specialisttandvården", Profiler.Folktandvård, 60), + ("Odontologisk digital klinik / Kariesmottagning", Profiler.Folktandvård, 20), + }); + + // === Regionservice → IT, fastigheter, lön, inköp, servicedesk, måltid === + var regionservice = Skapa("Regionservice", OrganizationUnitType.Forvaltning, regionId); + Skapa("Området IT", OrganizationUnitType.Enhet, regionservice.Id, Profiler.AdminService, 160); + Skapa("Regionfastigheter", OrganizationUnitType.Enhet, regionservice.Id, Profiler.Fastighet, 220); + Skapa("Lönecenter", OrganizationUnitType.Enhet, regionservice.Id, Profiler.AdminService, 60); + Skapa("Upphandling och inköp", OrganizationUnitType.Enhet, regionservice.Id, Profiler.AdminService, 70); + Skapa("Servicedesk", OrganizationUnitType.Enhet, regionservice.Id, Profiler.AdminService, 60); + Skapa("Måltidsservice", OrganizationUnitType.Enhet, regionservice.Id, Profiler.Måltid, 90); + + // === Regionkansliet → stab, HR, ekonomi, kommunikation === + var kansli = Skapa("Regionkansliet", OrganizationUnitType.Forvaltning, regionId); + Skapa("Regionkansliet stab", OrganizationUnitType.Enhet, kansli.Id, Profiler.AdminService, 90); + Skapa("HR-avdelningen", OrganizationUnitType.Enhet, kansli.Id, Profiler.AdminService, 110); + Skapa("Ekonomiavdelningen", OrganizationUnitType.Enhet, kansli.Id, Profiler.AdminService, 90); + Skapa("Kommunikationsavdelningen", OrganizationUnitType.Enhet, kansli.Id, Profiler.AdminService, 45); + + // === Regional utvecklingsförvaltningen → utveckling, energi, folkhälsa === + var regutv = Skapa("Regional utvecklingsförvaltningen", OrganizationUnitType.Forvaltning, regionId); + Skapa("Regional utveckling", OrganizationUnitType.Enhet, regutv.Id, Profiler.RegionalUtveckling, 90); + Skapa("Energikontoret", OrganizationUnitType.Enhet, regutv.Id, Profiler.RegionalUtveckling, 25); + Skapa("Folkhälsoenheten", OrganizationUnitType.Enhet, regutv.Id, Profiler.RegionalUtveckling, 30); + + // === Kultur och bildning → kultur, bibliotek, scenkonst, folkhögskolor === + var kultur = Skapa("Kultur och bildning", OrganizationUnitType.Forvaltning, regionId); + Skapa("Kulturenheten", OrganizationUnitType.Enhet, kultur.Id, Profiler.Kultur, 60); + Skapa("Regionbibliotek Örebro län", OrganizationUnitType.Enhet, kultur.Id, Profiler.Kultur, 40); + Skapa("Örebro länsteater", OrganizationUnitType.Enhet, kultur.Id, Profiler.Kultur, 45); + Skapa("Örebro Konserthus / Länsmusiken", OrganizationUnitType.Enhet, kultur.Id, Profiler.Kultur, 45); + Skapa("Fellingsbro folkhögskola", OrganizationUnitType.Enhet, kultur.Id, Profiler.Folkhögskola, 55); + Skapa("Kävesta folkhögskola", OrganizationUnitType.Enhet, kultur.Id, Profiler.Folkhögskola, 50); + + // === Kollektivtrafik / Länstrafiken === + var trafik = Skapa("Kollektivtrafikförvaltningen", OrganizationUnitType.Forvaltning, regionId); + Skapa("Länstrafiken Örebro", OrganizationUnitType.Enhet, trafik.Id, Profiler.Kollektivtrafik, 90); + Skapa("Kollektivtrafik stab", OrganizationUnitType.Enhet, trafik.Id, Profiler.Kollektivtrafik, 40); + + return (enheter, bemanningsbara); + } +} diff --git a/src/Infrastructure/Persistence/Demo/PersonnummerGenerator.cs b/src/Infrastructure/Persistence/Demo/PersonnummerGenerator.cs new file mode 100644 index 0000000..9f5c963 --- /dev/null +++ b/src/Infrastructure/Persistence/Demo/PersonnummerGenerator.cs @@ -0,0 +1,113 @@ +using RegionHR.SharedKernel.Domain; + +namespace RegionHR.Infrastructure.Persistence.Demo; + +/// +/// Genererar giltiga, unika svenska personnummer för demo-befolkningen. +/// +/// Ett personnummer byggs som YYYYMMDD + 3-siffrigt födelsenummer (NNN) + +/// en beräknad Luhn-kontrollsiffra (aldrig slumpad). Näst sista siffran +/// (sista siffran i NNN) styr juridiskt kön: jämn = Kvinna, udda = Man — vilket +/// hålls konsekvent med det valda förnamnets kön. +/// +/// Unikhet garanteras via en över det normaliserade +/// 12-siffriga värdet. Varje returnerat nummer valideras dessutom mot +/// -värdesobjektet (som kör Luhn på nytt), så att +/// generatorn inte kan producera något som systemet självt skulle underkänna. +/// +public sealed class PersonnummerGenerator +{ + private readonly HashSet _anvanda; + private readonly Random _rng; + + /// Slumpkälla (injiceras för deterministiska tester). + /// + /// Redan använda personnummer (t.ex. de handplockade demo-användarna) som + /// generatorn måste undvika att kollidera med. + /// + public PersonnummerGenerator(Random? rng = null, IEnumerable? reserverade = null) + { + _rng = rng ?? new Random(); + _anvanda = new HashSet(); + if (reserverade is not null) + { + foreach (var r in reserverade) + { + var normal = Normalisera(r); + if (normal is not null) _anvanda.Add(normal); + } + } + } + + /// Antal hittills genererade (exklusive reserverade). + public int AntalGenererade { get; private set; } + + /// + /// Returnerar ett nytt, unikt och giltigt personnummer för en person med + /// angiven ålder och kön (per ). + /// + public Personnummer NastaUnikt(int alder, bool arKvinna, DateOnly idag) + { + // Rimlighetsvakt: åldersintervall 18–100 (demo använder 20–67). + if (alder < 18) alder = 18; + if (alder > 100) alder = 100; + + // Försök tills ett unikt nummer hittas. Sökrymden (≈17 000 datum × 1000 + // födelsenummer) är enorm jämfört med ~11 000 personer → i praktiken 0–1 omtag. + for (var forsok = 0; forsok < 10_000; forsok++) + { + var fodelsear = idag.Year - alder; + var manad = _rng.Next(1, 13); + var dag = _rng.Next(1, 29); // 1–28 är alltid en giltig dag i alla månader + + // 3-siffrigt födelsenummer där sista siffran matchar kön. + var forsta = _rng.Next(0, 10); + var andra = _rng.Next(0, 10); + var sista = arKvinna + ? _rng.Next(0, 5) * 2 // 0,2,4,6,8 + : _rng.Next(0, 5) * 2 + 1; // 1,3,5,7,9 + if (forsta == 0 && andra == 0 && sista == 0) continue; // undvik NNN = 000 + + var yyyymmdd = $"{fodelsear:D4}{manad:D2}{dag:D2}"; + var nnn = $"{forsta}{andra}{sista}"; + var kontroll = BeraknaLuhnKontrollsiffra(yyyymmdd[2..] + nnn); // YYMMDD + NNN (9 siffror) + var tolvSiffror = yyyymmdd + nnn + kontroll; + + if (!_anvanda.Add(tolvSiffror)) continue; // krock → nytt försök + + // Slutlig sanity-check mot värdesobjektet (format + Luhn + könsparitet). + var pnr = new Personnummer(tolvSiffror); + AntalGenererade++; + return pnr; + } + + throw new InvalidOperationException( + "Kunde inte generera ett unikt personnummer efter 10 000 försök — sökrymden bör vara uttömd först."); + } + + /// + /// Beräknar Luhn-kontrollsiffran för de 9 siffrorna YYMMDDNNN enligt exakt samma + /// algoritm som validerar med (multiplikator 2,1,2,1…). + /// + public static int BeraknaLuhnKontrollsiffra(string nioSiffror) + { + if (nioSiffror.Length != 9) + throw new ArgumentException("Luhn-underlaget måste vara exakt 9 siffror (YYMMDDNNN).", nameof(nioSiffror)); + + var sum = 0; + for (var i = 0; i < 9; i++) + { + var d = nioSiffror[i] - '0'; + var m = d * (i % 2 == 0 ? 2 : 1); + sum += m > 9 ? m - 9 : m; + } + return (10 - sum % 10) % 10; + } + + private static string? Normalisera(string input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + var cleaned = input.Replace(" ", "").Replace("-", "").Replace("+", ""); + return cleaned.Length == 12 && cleaned.All(char.IsDigit) ? cleaned : null; + } +} diff --git a/src/Infrastructure/Persistence/Demo/SvenskaNamn.cs b/src/Infrastructure/Persistence/Demo/SvenskaNamn.cs new file mode 100644 index 0000000..1dcc236 --- /dev/null +++ b/src/Infrastructure/Persistence/Demo/SvenskaNamn.cs @@ -0,0 +1,90 @@ +namespace RegionHR.Infrastructure.Persistence.Demo; + +/// +/// Inbäddade listor med vanliga svenska förnamn (kvinnliga/manliga) och efternamn. +/// Används av den procedurella demo-befolkningsgeneratorn för att skapa realistiska, +/// könskonsekventa namnkombinationer. Ren data — inga externa beroenden. +/// +/// ~150 kvinnliga + ~150 manliga förnamn + ~200 efternamn ger flera hundratusen +/// unika hela-namn-kombinationer, vilket räcker gott för en ~11 000-personers styrka. +/// +public static class SvenskaNamn +{ + /// Kvinnliga förnamn (juridiskt kön Kvinna → jämn näst sista personnummersiffra). + public static readonly string[] Kvinnonamn = + [ + "Anna", "Maria", "Margareta", "Elisabeth", "Eva", "Kristina", "Birgitta", "Karin", + "Marie", "Ingrid", "Christina", "Linnéa", "Kerstin", "Marianne", "Lena", "Helena", + "Emma", "Johanna", "Linda", "Sofia", "Anita", "Sara", "Malin", "Hanna", "Inger", + "Cecilia", "Ulla", "Susanne", "Annika", "Åsa", "Camilla", "Barbro", "Jenny", "Ida", + "Josefin", "Julia", "Ellen", "Alice", "Astrid", "Wilma", "Ebba", "Elin", "Agneta", + "Gunilla", "Katarina", "Monica", "Berit", "Yvonne", "Carina", "Britt", "Louise", + "Frida", "Amanda", "Matilda", "Klara", "Isabelle", "Moa", "Nathalie", "Rebecca", + "Elsa", "Alva", "Lovisa", "Saga", "Alma", "Lilly", "Maja", "Olivia", "Nova", "Ella", + "Freja", "Agnes", "Signe", "Vera", "Ines", "Stella", "Selma", "Tuva", "Iris", "Molly", + "Ellie", "Juni", "Siri", "Nellie", "Tilde", "Meja", "Hedda", "Wilhelmina", "Ester", + "Edith", "Greta", "Dagny", "Solveig", "Gudrun", "Rut", "Majken", "Viola", "Doris", + "Gerd", "Rakel", "Gun", "Ann", "Ingegerd", "Siv", "Britt-Marie", "Ann-Christine", + "Gun-Britt", "Marita", "Pia", "Therese", "Petra", "Charlotta", "Emelie", "Sofie", + "Caroline", "Madeleine", "Victoria", "Emilia", "Felicia", "Cornelia", "Tindra", + "Melissa", "Isabella", "Alicia", "Leia", "My", "Ellinor", "Hilda", "Ottilia", + "Elvira", "Livia", "Thea", "Nora", "Lykke", "Minna", "Ronja", "Idun", "Gabriella", + "Amelia", "Beata", "Desirée", "Henrietta", "Ingela", "Lisbeth", "Ulrika", "Vendela", + "Zara", "Nadia", "Fatima", "Layla", "Sonja", "Vanja", "Majvor", "Elly" + ]; + + /// Manliga förnamn (juridiskt kön Man → udda näst sista personnummersiffra). + public static readonly string[] Mansnamn = + [ + "Erik", "Lars", "Karl", "Anders", "Per", "Johan", "Nils", "Lennart", "Mikael", "Jan", + "Hans", "Carl", "Sven", "Peter", "Fredrik", "Daniel", "Gunnar", "Bengt", "Bo", "Åke", + "Mats", "Thomas", "Andreas", "Stefan", "Göran", "Björn", "Christer", "Ulf", "Magnus", + "Leif", "Rolf", "Kjell", "Marcus", "Henrik", "Roger", "Bertil", "David", "Alexander", + "Emil", "Oscar", "Anton", "Gustav", "Filip", "Ludvig", "William", "Lucas", "Elias", + "Hugo", "Liam", "Oliver", "Adam", "Isak", "Vincent", "Theodor", "Axel", "Leo", "Noah", + "Arvid", "Albin", "Melvin", "Wilmer", "Charlie", "Viktor", "Simon", "Jonathan", + "Sebastian", "Rasmus", "Robin", "Jesper", "Pontus", "Linus", "Martin", "Jonas", + "Niklas", "Patrik", "Joakim", "Tobias", "Jakob", "Samuel", "Benjamin", "Felix", + "Gabriel", "Love", "Sixten", "Frank", "Malte", "Loke", "Vidar", "Alfred", "Edvin", + "Folke", "Nataniel", "Kevin", "Kasper", "Måns", "Melker", "Milton", "Vilgot", "Ture", + "Ivar", "Harald", "Ragnar", "Sigge", "Sten", "Torbjörn", "Håkan", "Roland", "Ingemar", + "Sune", "Evert", "Holger", "Yngve", "Verner", "Assar", "Gösta", "Ove", "Tage", "Valter", + "Georg", "Curt", "Alf", "Arne", "Egon", "Helge", "Knut", "Olof", "Sigurd", "Vilhelm", + "Aron", "Casper", "Dante", "Elton", "Hjalmar", "Jack", "Joel", "Kian", "Ali", "Omar", + "Mohammed", "Hassan", "Ahmad", "Ibrahim", "Yusuf", "Sten-Åke", "Karl-Erik", "Jan-Erik", + "Lars-Göran", "Bror", "Einar", "Gottfrid", "Ejnar" + ]; + + /// Vanliga svenska efternamn (könsneutrala). + public static readonly string[] Efternamn = + [ + "Andersson", "Johansson", "Karlsson", "Nilsson", "Eriksson", "Larsson", "Olsson", + "Persson", "Svensson", "Gustafsson", "Pettersson", "Jonsson", "Jansson", "Hansson", + "Bengtsson", "Jönsson", "Lindberg", "Jakobsson", "Magnusson", "Olofsson", "Lindström", + "Lindqvist", "Lindgren", "Berg", "Axelsson", "Bergström", "Lundberg", "Lind", + "Lundgren", "Lundqvist", "Mattsson", "Berglund", "Fredriksson", "Sandberg", + "Henriksson", "Forsberg", "Sjöberg", "Wallin", "Engström", "Eklund", "Danielsson", + "Håkansson", "Lundin", "Gunnarsson", "Holm", "Bergman", "Björk", "Wikström", + "Isaksson", "Fransson", "Bergqvist", "Nyström", "Holmberg", "Arvidsson", "Löfgren", + "Söderberg", "Nyberg", "Blomqvist", "Claesson", "Nordström", "Mårtensson", + "Lundström", "Björklund", "Eliasson", "Pålsson", "Viklund", "Berggren", "Sandström", + "Nordin", "Lindholm", "Hedlund", "Dahlberg", "Hellström", "Sjögren", "Abrahamsson", + "Ek", "Blom", "Åberg", "Gustavsson", "Sundberg", "Öberg", "Strömberg", "Ottosson", + "Hermansson", "Backlund", "Sundström", "Åkesson", "Norberg", "Dahl", "Falk", "Ström", + "Åström", "Blomgren", "Karlström", "Franzén", "Sundqvist", "Holmgren", "Samuelsson", + "Nordqvist", "Ahlström", "Lund", "Öhman", "Månsson", "Rosén", "Hedström", "Sjölund", + "Ivarsson", "Molin", "Wallander", "Åkerlund", "Ohlsson", "Petersson", "Sjöström", + "Sundin", "Almqvist", "Nyholm", "Rydberg", "Jonasson", "Palm", "Östberg", "Bohlin", + "Ekström", "Melin", "Norling", "Hägglund", "Björkman", "Sundell", "Wennberg", + "Ekberg", "Dahlström", "Boström", "Löfqvist", "Sjödin", "Kjellberg", "Norén", + "Wester", "Enqvist", "Grönlund", "Lindell", "Bergstrand", "Wiberg", "Svahn", + "Zetterlund", "Sköld", "Ljung", "Frisk", "Ahlin", "Rask", "Modig", "Stål", "Lönn", + "Kvist", "Florén", "Lindblad", "Sjöqvist", "Hallberg", "Wallgren", "Bäckström", + "Palmqvist", "Källström", "Rönnberg", "Malm", "Hjort", "Löf", "Ohlin", "Tegnér", + "Brandt", "Cederholm", "Hall", "Josefsson", "Lager", "Nordlund", "Roos", "Törnqvist", + "Wahlgren", "Åhlén", "Sylvan", "Wikner", "Ceder", "Ternström", "Halvarsson", + "Rehn", "Björnsson", "Ryman", "Öqvist", "Grahn", "Norman", "Ranger", "Söderlund", + "Wallström", "Åhman", "Ljungqvist", "Fagerberg", "Sträng", "Elmqvist", "Byström", + "Kihlström", "Rudberg", "Ahmadi", "Hussein", "Rahman", "Khan", "Yilmaz", "Nguyen" + ]; +} diff --git a/src/Infrastructure/Persistence/SeedData.cs b/src/Infrastructure/Persistence/SeedData.cs index dace05b..3fa883e 100644 --- a/src/Infrastructure/Persistence/SeedData.cs +++ b/src/Infrastructure/Persistence/SeedData.cs @@ -2203,5 +2203,20 @@ 3. Kontakta HR-systemförvaltning via servicedesk db.DevelopmentPlans.Add(utvPlanAnna); await db.SaveChangesAsync(); + + // === Procedurell demo-befolkning: ~11 000 anställda över hela Region Örebro län === + // Bygger ut organisationsträdet (förvaltningar, sjukhus + kliniker, vårdcentraler, + // folktandvård, service, kultur, kollektivtrafik) och befolkar det. Körs EFTER all + // handplockad demo-data ovan — de namngivna demo-användarna (Anna Svensson, + // Eva Nilsson, Karl Berg m.fl.) och deras rika data lämnas helt orörda. Generatorn + // sköter sina egna batchade SaveChanges; guarden överst (tom DB) håller idempotensen. + await Demo.DemoBefolkningsGenerator.GenereraAsync( + db, + regionId: region.Id, + usoId: sjukhus.Id, + abAvtalsId: ab.Id, + // Reservera de FAKTISKT lagrade numren (CreateValidated kan ha räknat om + // kontrollsiffran) så generatorn garanterat inte krockar med demo-användarna. + reserveradePersonnummer: seedEmployees.Select(e => (string)Personnummer.CreateValidated(e.Pnr))); } } diff --git a/src/Web/Components/Pages/Anstallda/Index.razor b/src/Web/Components/Pages/Anstallda/Index.razor index bf9ee70..8d390b3 100644 --- a/src/Web/Components/Pages/Anstallda/Index.razor +++ b/src/Web/Components/Pages/Anstallda/Index.razor @@ -1,6 +1,7 @@ @page "/anstallda" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Export +@using RegionHR.Web.Services @using Microsoft.Extensions.Localization @inject RegionHR.Web.Services.AnstallningService Service @inject RegionHR.Web.Services.UnitScopeService Scope @@ -16,12 +17,12 @@
+ Disabled="_exporterar" Style="min-height: 48px;"> CSV + Disabled="_exporterar" Style="min-height: 48px;"> @L["ExportExcel"]
- - -@if (_anstallda.Count == 0 && !_loading) -{ - @L["NoEmployeesFound"] -} -else -{ - - - - @L["Name"] - @L["PersonalNumber"] - @L["JobTitle"] - @L["EmploymentForm"] - @L["EmploymentRate"] - - - - - @foreach (var a in _anstallda) - { - - @a.Fornamn @a.Efternamn - @a.PersonnummerMaskerat - @a.Befattning - @a.Anstallningsform - @a.Sysselsattningsgrad% - @L["Open"] - - } - - -} - -@if (_loading) -{ - -} + + + + @if (!Scope.IsUnitScoped && _enheter.Count > 0) + { + + @foreach (var e in _enheter) + { + @e.Namn (@e.Kostnadsstalle) + } + + } + + + + @L["Name"] + @L["PersonalNumber"] + @L["JobTitle"] + @L["Unit"] + @L["EmploymentForm"] + @L["EmploymentRate"] + + + + @context.Fornamn @context.Efternamn + @context.PersonnummerMaskerat + @context.Befattning + @context.Enhet + @context.Anstallningsform + @context.Sysselsattningsgrad% + + @L["Open"] + + + + @L["NoEmployeesFound"] + + + + + + + + @if (_visarCsv) { @@ -95,62 +106,98 @@ else } @code { + private MudTable _table = null!; private string _sokterm = ""; - private bool _loading; - private List _anstallda = []; + private Guid? _valdEnhet; + private bool _exporterar; + private List _enheter = []; protected override async Task OnInitializedAsync() { - await LaddaAnstallda(); + // Enhetsväljaren visas bara för HR/Admin (chef är låst till sin egen enhet). + if (!Scope.IsUnitScoped) + { + try { _enheter = await Service.HamtaEnheterAsync(); } + catch { _enheter = []; } + } } - private async Task Sok(string term) - { - _sokterm = term; - await LaddaAnstallda(); - } + private Task ReloadTable() => _table.ReloadServerData(); - private async Task LaddaAnstallda() + /// + /// ServerData-callback för MudTable: laddar EN sida ur DB (Skip/Take + totalantal). + /// En chef scopas till sin enhet direkt i SQL-frågan; kan enheten inte härledas + /// visas ingenting (fail-closed) i stället för hela organisationen. + /// + private async Task> LaddaData(TableState state, CancellationToken token) { - _loading = true; + var (visa, enhet) = await BestamEnhetsfilterAsync(); + if (!visa) + return new TableData { Items = [], TotalItems = 0 }; + try { - 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; + var sida = await Service.HamtaSidaAsync( + state.Page * state.PageSize, + state.PageSize, + string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm, + enhet, + token); + return new TableData { Items = sida.Items, TotalItems = sida.TotalAntal }; } catch { - _anstallda = []; + return new TableData { Items = [], TotalItems = 0 }; + } + } + + /// + /// Löser det effektiva enhetsfiltret. Returnerar (visa, enhet): + /// visa=false innebär fail-closed (chef utan härledbar enhet → visa inget). + /// + private async Task<(bool Visa, Guid? Enhet)> BestamEnhetsfilterAsync() + { + if (Scope.IsUnitScoped) + { + var unit = await Scope.GetCurrentUnitIdAsync(); + return unit is null ? (false, (Guid?)null) : (true, unit); } - _loading = false; + return (true, _valdEnhet); + } + + private void OppnaRad(TableRowClickEventArgs args) + { + if (args.Item is { } rad) + Nav.NavigateTo($"/anstallda/{rad.Id.Value}"); } private bool _visarCsv; private string _csvInnehall = ""; private bool _kopierad; - private void ExporteraCsv() + private async Task> HamtaExportdataAsync() + { + var (visa, enhet) = await BestamEnhetsfilterAsync(); + if (!visa) return []; + return await Service.HamtaForExportAsync( + string.IsNullOrWhiteSpace(_sokterm) ? null : _sokterm, enhet); + } + + private async Task ExporteraCsv() { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"{L["FirstName"]};{L["LastName"]};{L["PersonalNumber"]};{L["JobTitle"]};{L["EmploymentForm"]};{L["EmploymentRate"]} (%)"); - foreach (var a in _anstallda) - sb.AppendLine($"{a.Fornamn};{a.Efternamn};{a.PersonnummerMaskerat};{a.Befattning};{a.Anstallningsform};{a.Sysselsattningsgrad}"); - _csvInnehall = sb.ToString(); - _visarCsv = true; - _kopierad = false; + _exporterar = true; + try + { + var rader = await HamtaExportdataAsync(); + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"{L["FirstName"]};{L["LastName"]};{L["PersonalNumber"]};{L["JobTitle"]};{L["Unit"]};{L["EmploymentForm"]};{L["EmploymentRate"]} (%)"); + foreach (var a in rader) + sb.AppendLine($"{a.Fornamn};{a.Efternamn};{a.PersonnummerMaskerat};{a.Befattning};{a.Enhet};{a.Anstallningsform};{a.Sysselsattningsgrad}"); + _csvInnehall = sb.ToString(); + _visarCsv = true; + _kopierad = false; + } + finally { _exporterar = false; } } private async Task KopieraCsv() @@ -168,27 +215,34 @@ else private async Task ExporteraExcel() { - var headers = new[] { L["FirstName"].Value, L["LastName"].Value, L["PersonalNumber"].Value, L["JobTitle"].Value, L["EmploymentForm"].Value, $"{L["EmploymentRate"]} (%)" }; - - var excelBytes = Export.ToExcel( - _anstallda, - L["Employees"].Value, - headers, - a => new object[] - { - a.Fornamn, - a.Efternamn, - a.PersonnummerMaskerat, - a.Befattning, - a.Anstallningsform, - a.Sysselsattningsgrad - }); - - var base64 = Convert.ToBase64String(excelBytes); - var timestamp = DateTime.Now.ToString("yyyy-MM-dd"); - await JS.InvokeVoidAsync("downloadFile", - $"anstallda-{timestamp}.xlsx", - base64, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + _exporterar = true; + try + { + var rader = await HamtaExportdataAsync(); + var headers = new[] { L["FirstName"].Value, L["LastName"].Value, L["PersonalNumber"].Value, L["JobTitle"].Value, L["Unit"].Value, L["EmploymentForm"].Value, $"{L["EmploymentRate"]} (%)" }; + + var excelBytes = Export.ToExcel( + rader, + L["Employees"].Value, + headers, + a => new object[] + { + a.Fornamn, + a.Efternamn, + a.PersonnummerMaskerat, + a.Befattning, + a.Enhet, + a.Anstallningsform, + a.Sysselsattningsgrad + }); + + var base64 = Convert.ToBase64String(excelBytes); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd"); + await JS.InvokeVoidAsync("downloadFile", + $"anstallda-{timestamp}.xlsx", + base64, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + } + finally { _exporterar = false; } } } diff --git a/src/Web/Components/Pages/Chef/Bemanning.razor b/src/Web/Components/Pages/Chef/Bemanning.razor index 2e01874..3970de0 100644 --- a/src/Web/Components/Pages/Chef/Bemanning.razor +++ b/src/Web/Components/Pages/Chef/Bemanning.razor @@ -73,19 +73,31 @@ else try { var idag = DateOnly.FromDateTime(DateTime.Today); - // Enhets-scoping: chef ser bara sin egen enhet (null = ingen scoping). - var scopeIds = await Scope.GetEmployeeIdsInScopeAsync(); var shifts = await db.ScheduledShifts .Where(s => s.Datum == idag && s.Status != ShiftStatus.Avbokad) .ToListAsync(); - if (scopeIds is not null) - shifts = shifts.Where(s => scopeIds.Contains(s.AnstallId.Value)).ToList(); if (shifts.Count > 0) { var anstallIds = shifts.Select(s => s.AnstallId).Distinct().ToList(); + // Endast anställda med pass idag laddas (bundet av dagens schema, aldrig hela + // registret) — inkl. anställningar för enhets-scoping. var employees = await db.Employees + .Include(e => e.Anstallningar) .Where(e => anstallIds.Contains(e.Id)).ToListAsync(); + + // Chef ser bara sin egen enhet. Enhets-id härleds billigt (cachat unit-id + // eller en enda uppslagsfråga) i stället för att materialisera alla anställda. + // Kan enheten inte härledas behålls nuvarande beteende (ingen scoping). + if (Scope.IsUnitScoped && await Scope.GetCurrentUnitIdAsync() is Guid enhetId) + { + var iEnhet = employees + .Where(e => e.AktivAnstallning(idag)?.EnhetId.Value == enhetId) + .Select(e => e.Id).ToHashSet(); + shifts = shifts.Where(s => iEnhet.Contains(s.AnstallId)).ToList(); + employees = employees.Where(e => iEnhet.Contains(e.Id)).ToList(); + } + var names = employees.ToDictionary(e => e.Id.Value, e => $"{e.Fornamn} {e.Efternamn}"); _passgrupper = shifts.GroupBy(s => s.PassTyp) diff --git a/src/Web/Components/Pages/Chef/MittTeam.razor b/src/Web/Components/Pages/Chef/MittTeam.razor index f665539..ff054ea 100644 --- a/src/Web/Components/Pages/Chef/MittTeam.razor +++ b/src/Web/Components/Pages/Chef/MittTeam.razor @@ -1,7 +1,9 @@ @page "/chef/team" @layout RegionHR.Web.Components.Layout.AdminLayout @using RegionHR.Infrastructure.Persistence +@using RegionHR.Core.Domain @using RegionHR.Leave.Domain +@using RegionHR.SharedKernel.Domain @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory @inject RegionHR.Web.Services.AuthService Auth @@ -91,46 +93,61 @@ else private bool _loading = true; private string? _error, _enhetNamn; + // Skyddstak: en enhets team laddas i sin helhet (ingen paginering här ännu). Taket + // hindrar att en oväntat stor enhet — eller en chef utan härledbar enhet — fryser vyn. + private const int TeamTak = 1000; + protected override async Task OnInitializedAsync() { await using var db = await DbFactory.CreateDbContextAsync(); try { var idag = DateOnly.FromDateTime(DateTime.Today); - var allEmployees = await db.Employees.Include(e => e.Anstallningar).OrderBy(e => e.Efternamn).ToListAsync(); - // Filter on chef's unit if auth provides EmployeeId - Guid? chefEnhetId = null; - if (Auth.EmployeeId.HasValue) + // Chefens enhet: använd i första hand det cachade unit-id:t, annars EN uppslagsfråga. + Guid? chefEnhetId = Auth.UnitId; + if (chefEnhetId is null && Auth.EmployeeId is Guid empId) { - var chef = allEmployees.FirstOrDefault(e => e.Id.Value == Auth.EmployeeId.Value); - var chefAnst = chef?.AktivAnstallning(idag); - if (chefAnst != null) - { - chefEnhetId = chefAnst.EnhetId.Value; - var enhet = await db.OrganizationUnits.FirstOrDefaultAsync(e => e.Id.Value == chefEnhetId.Value); - _enhetNamn = enhet?.Namn; - } + var chef = await db.Employees.Include(e => e.Anstallningar) + .FirstOrDefaultAsync(e => e.Id == EmployeeId.From(empId)); + chefEnhetId = chef?.AktivAnstallning(idag)?.EnhetId.Value; } - var employees = chefEnhetId.HasValue - ? allEmployees.Where(e => e.AktivAnstallning(idag)?.EnhetId.Value == chefEnhetId.Value).ToList() - : allEmployees; + // Teamet filtreras på enhet DIREKT i SQL — hela personalregistret laddas aldrig. + IQueryable teamQuery = db.Employees.Include(e => e.Anstallningar); + if (chefEnhetId is Guid enhetId) + { + var oid = OrganizationId.From(enhetId); + teamQuery = teamQuery.Where(e => e.Anstallningar.Any(a => + a.EnhetId == oid && + a.Giltighetsperiod.Start <= idag && + (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag))); + _enhetNamn = (await db.OrganizationUnits.FirstOrDefaultAsync(o => o.Id == oid))?.Namn; + } - // Aktiva frånvaroperioder idag + var employees = await teamQuery + .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn) + .Take(TeamTak) + .ToListAsync(); + + var teamGuids = employees.Select(e => e.Id.Value).ToList(); + + // Aktiva frånvaroperioder idag — bara för teamet (inte hela regionen). var aktivFranvaro = await db.LeaveRequests .Where(r => r.FranDatum <= idag && r.TillDatum >= idag - && (r.Status == LeaveRequestStatus.Inskickad || r.Status == LeaveRequestStatus.Godkand)) + && (r.Status == LeaveRequestStatus.Inskickad || r.Status == LeaveRequestStatus.Godkand) + && teamGuids.Contains(r.AnstallId)) .ToListAsync(); var franvaroPerAnstall = aktivFranvaro.ToDictionary(r => r.AnstallId, r => r.Typ); - // Semestersaldo - var balances = await db.VacationBalances.Where(b => b.Ar == idag.Year).ToListAsync(); + // Semestersaldo — bara för teamet. + var balances = await db.VacationBalances + .Where(b => b.Ar == idag.Year && teamGuids.Contains(b.AnstallId)) + .ToListAsync(); var balansPerAnstall = balances.ToDictionary(b => b.AnstallId, b => b.TillgangligaDagar); - // Enhetnamn - var enheter = await db.OrganizationUnits.ToListAsync(); - var enhetNamn = enheter.ToDictionary(e => e.Id.Value, e => e.Namn); + // Enhetnamn (liten tabell) + var enhetNamn = await db.OrganizationUnits.ToDictionaryAsync(e => e.Id.Value, e => e.Namn); _team = employees.Select(e => { diff --git a/src/Web/Components/Pages/Home.razor b/src/Web/Components/Pages/Home.razor index ba9da7e..920285e 100644 --- a/src/Web/Components/Pages/Home.razor +++ b/src/Web/Components/Pages/Home.razor @@ -124,20 +124,29 @@ else if (_showBemanning && _bemanningPerEnhet.Count == 0 && !_loading) { var idag = DateOnly.FromDateTime(DateTime.Today); - // Headcount: anställda med aktiv anställning - var employees = await db.Employees.Include(e => e.Anstallningar).ToListAsync(); - _headcount = employees.Count(e => e.AktivAnstallning(idag) != null); + // Headcount: anställda med aktiv anställning — SQL COUNT + EXISTS, inga entiteter + // materialiseras (skalar till tiotusentals anställda). + _headcount = await db.Employees.CountAsync(e => e.Anstallningar.Any(a => + a.Giltighetsperiod.Start <= idag && + (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag))); // LAS-alarm _lasAlarm = await db.LASAccumulations .CountAsync(l => l.Status == LASStatus.NaraGrans || l.Status == LASStatus.KritiskNara); - // Positioner — en fråga, gruppera status i minnet - var positions = await db.Positions_Table.ToListAsync(); - var statusCounts = positions.GroupBy(p => p.Status) - .ToDictionary(g => g.Key, g => g.Count()); - var aktiva = statusCounts.GetValueOrDefault(PositionStatus.Aktiv); - var vakanta = statusCounts.GetValueOrDefault(PositionStatus.Vakant); + // Positioner per enhet — aggregeras i databasen (GROUP BY), inte i minnet. + var perEnhetRaw = await db.Positions_Table + .GroupBy(p => p.EnhetId) + .Select(g => new + { + EnhetId = g.Key, + Aktiva = g.Sum(p => p.Status == PositionStatus.Aktiv ? 1 : 0), + Vakanta = g.Sum(p => p.Status == PositionStatus.Vakant ? 1 : 0) + }) + .ToListAsync(); + + var aktiva = perEnhetRaw.Sum(x => x.Aktiva); + var vakanta = perEnhetRaw.Sum(x => x.Vakanta); _vakantaPositioner = vakanta; // Väntande godkännanden @@ -148,16 +157,14 @@ else if (_showBemanning && _bemanningPerEnhet.Count == 0 && !_loading) var total = aktiva + vakanta; _bemanningsgrad = total > 0 ? (int)Math.Round(100.0 * aktiva / total) : 0; - // Bemanning per enhet - var enheter = await db.OrganizationUnits.ToListAsync(); - var enhetNamn = enheter.ToDictionary(e => e.Id.Value, e => e.Namn); + // Bemanning per enhet — enhetsnamn (liten tabell) slås upp för de grupperade raderna. + var enhetNamn = await db.OrganizationUnits.ToDictionaryAsync(e => e.Id.Value, e => e.Namn); - _bemanningPerEnhet = positions - .GroupBy(p => p.EnhetId) + _bemanningPerEnhet = perEnhetRaw .Select(g => new EnhetBemanningVm( - enhetNamn.TryGetValue(g.Key, out var namn) ? namn : L["UnknownUnit"].Value, - g.Count(p => p.Status == PositionStatus.Aktiv), - g.Count(p => p.Status == PositionStatus.Vakant))) + enhetNamn.TryGetValue(g.EnhetId, out var namn) ? namn : L["UnknownUnit"].Value, + g.Aktiva, + g.Vakanta)) .OrderByDescending(e => e.Vakanta) .ToList(); } diff --git a/src/Web/Resources/SharedResources.en.resx b/src/Web/Resources/SharedResources.en.resx index b210138..56c4b31 100644 --- a/src/Web/Resources/SharedResources.en.resx +++ b/src/Web/Resources/SharedResources.en.resx @@ -29,7 +29,6 @@ Log in Log out - Log out Choose login method BankID All employees diff --git a/src/Web/Resources/SharedResources.resx b/src/Web/Resources/SharedResources.resx index 8a053d7..8e60bbd 100644 --- a/src/Web/Resources/SharedResources.resx +++ b/src/Web/Resources/SharedResources.resx @@ -29,7 +29,6 @@ Logga in Logga ut - Logga ut Valj inloggningsmetod BankID Alla anstallda diff --git a/src/Web/Services/AnstallningService.cs b/src/Web/Services/AnstallningService.cs index ebee8d8..2a51f90 100644 --- a/src/Web/Services/AnstallningService.cs +++ b/src/Web/Services/AnstallningService.cs @@ -12,38 +12,133 @@ public class AnstallningService public AnstallningService(IDbContextFactory dbFactory) => _dbFactory = dbFactory; + /// + /// Bakåtkompatibel "hämta upp till 100"-lista (används av bl.a. mallgeneratorns + /// medarbetarväljare). För den paginerade listvyn: använd . + /// public async Task> HamtaAllaAsync(string? sokterm = null, CancellationToken ct = default) { await using var db = await _dbFactory.CreateDbContextAsync(ct); - var query = db.Employees + var idag = DateOnly.FromDateTime(DateTime.Today); + + var employees = await ByggFiltreradFraga(db, idag, sokterm, null) + .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn) + .Take(100) + .Include(e => e.Anstallningar) + .ToListAsync(ct); + + var enhetNamn = await HamtaEnhetNamnAsync(db, ct); + return employees.Select(e => MapItem(e, idag, enhetNamn)).ToList(); + } + + /// + /// Serversidig paginering för anställd-listan. Laddar BARA en sida (typiskt 25–50) + /// ur databasen plus totalantal — skalar till tiotusentals anställda utan att frysa + /// Blazor-circuiten. Filtrerar valfritt på fritext (namn, befattning, e-post) och/eller + /// en exakt enhet (, används både för enhetsfilter i UI och + /// för chefs-scoping). Aktiv anställning bestäms i SQL via giltighetsperioden. + /// + /// Antal rader att hoppa över (sida × sidstorlek). + /// Sidstorlek (klampas till 1–200). + public async Task> HamtaSidaAsync( + int hoppaOver, int taAntal, string? sokterm = null, Guid? enhetId = null, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var idag = DateOnly.FromDateTime(DateTime.Today); + var query = ByggFiltreradFraga(db, idag, sokterm, enhetId); + + var total = await query.CountAsync(ct); + + var employees = await query + .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn) + .Skip(Math.Max(0, hoppaOver)) + .Take(Math.Clamp(taAntal, 1, 200)) + .Include(e => e.Anstallningar) + .ToListAsync(ct); + + var enhetNamn = await HamtaEnhetNamnAsync(db, ct); + var items = employees.Select(e => MapItem(e, idag, enhetNamn)).ToList(); + return new PagedResult(items, total); + } + + /// + /// Hämtar hela det filtrerade urvalet (utan paginering) för CSV/Excel-export. + /// Har ett tak () som skyddsnät mot en oavsiktlig helexport + /// av hela registret; höj vid behov. + /// + public async Task> HamtaForExportAsync( + string? sokterm = null, Guid? enhetId = null, int max = 20000, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var idag = DateOnly.FromDateTime(DateTime.Today); + + var employees = await ByggFiltreradFraga(db, idag, sokterm, enhetId) + .OrderBy(e => e.Efternamn).ThenBy(e => e.Fornamn) + .Take(Math.Clamp(max, 1, 100000)) .Include(e => e.Anstallningar) - .AsQueryable(); + .ToListAsync(ct); + + var enhetNamn = await HamtaEnhetNamnAsync(db, ct); + return employees.Select(e => MapItem(e, idag, enhetNamn)).ToList(); + } + + /// + /// Bygger den filtrerade grundfrågan (utan paginering/materialisering). All filtrering + /// sker i SQL: aktiv-anställnings-scoping via giltighetsperioden och fritext via LIKE. + /// + private static IQueryable ByggFiltreradFraga( + RegionHRDbContext db, DateOnly idag, string? sokterm, Guid? enhetId) + { + var query = db.Employees.AsQueryable(); + + if (enhetId is Guid eid) + { + var oid = OrganizationId.From(eid); + query = query.Where(e => e.Anstallningar.Any(a => + a.EnhetId == oid && + a.Giltighetsperiod.Start <= idag && + (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag))); + } if (!string.IsNullOrWhiteSpace(sokterm)) { - var term = sokterm.ToLower(); + var t = sokterm.Trim().ToLower(); query = query.Where(e => - e.Fornamn.ToLower().Contains(term) || - e.Efternamn.ToLower().Contains(term)); + e.Fornamn.ToLower().Contains(t) || + e.Efternamn.ToLower().Contains(t) || + (e.Fornamn + " " + e.Efternamn).ToLower().Contains(t) || + (e.Epost != null && e.Epost.ToLower().Contains(t)) || + e.Anstallningar.Any(a => + a.Befattningstitel != null && a.Befattningstitel.ToLower().Contains(t))); } - var employees = await query.OrderBy(e => e.Efternamn).Take(100).ToListAsync(ct); + return query; + } - return employees.Select(e => - { - var aktiv = e.Anstallningar.FirstOrDefault(a => - a.Giltighetsperiod.Start <= DateOnly.FromDateTime(DateTime.Today) && - (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= DateOnly.FromDateTime(DateTime.Today))); - return new EmployeeListItem( - e.Id, - e.Fornamn, - e.Efternamn, - e.Personnummer.ToMaskedString(), - e.Epost, - aktiv?.Befattningstitel ?? "-", - aktiv?.Anstallningsform.ToString() ?? "-", - aktiv?.Sysselsattningsgrad.Value ?? 0); - }).ToList(); + /// Uppslag enhets-id → namn. Organisationsenheter är en liten tabell (≪ anställda). + private static async Task> HamtaEnhetNamnAsync( + RegionHRDbContext db, CancellationToken ct) + { + var enheter = await db.OrganizationUnits + .Select(o => new { o.Id, o.Namn }) + .ToListAsync(ct); + return enheter.ToDictionary(x => x.Id.Value, x => x.Namn); + } + + private static EmployeeListItem MapItem(Employee e, DateOnly idag, IReadOnlyDictionary enhetNamn) + { + var aktiv = e.AktivAnstallning(idag); + var enhet = aktiv != null && enhetNamn.TryGetValue(aktiv.EnhetId.Value, out var namn) ? namn : "-"; + return new EmployeeListItem( + e.Id, + e.Fornamn, + e.Efternamn, + e.Personnummer.ToMaskedString(), + e.Epost, + aktiv?.Befattningstitel ?? "-", + aktiv?.Anstallningsform.ToString() ?? "-", + aktiv?.Sysselsattningsgrad.Value ?? 0, + enhet); } public async Task HamtaAsync(EmployeeId id, CancellationToken ct = default) @@ -245,7 +340,11 @@ public record EmployeeListItem( string? Epost, string Befattning, string Anstallningsform, - decimal Sysselsattningsgrad); + decimal Sysselsattningsgrad, + string Enhet); + +/// En sida ur ett större resultat: raderna + totalantalet matchande poster. +public record PagedResult(IReadOnlyList Items, int TotalAntal); public record EnhetVal(OrganizationId Id, string Namn, string Kostnadsstalle); public record AvtalVal(CollectiveAgreementId Id, string Namn); diff --git a/tests/RegionHR.Infrastructure.Tests/Demo/DemoBefolkningsGeneratorTests.cs b/tests/RegionHR.Infrastructure.Tests/Demo/DemoBefolkningsGeneratorTests.cs new file mode 100644 index 0000000..cecf10c --- /dev/null +++ b/tests/RegionHR.Infrastructure.Tests/Demo/DemoBefolkningsGeneratorTests.cs @@ -0,0 +1,93 @@ +using RegionHR.Infrastructure.Persistence.Demo; +using RegionHR.SharedKernel.Domain; +using Xunit; + +namespace RegionHR.Infrastructure.Tests.Demo; + +/// +/// Enhetstester för den procedurella demo-befolkningsgeneratorns rena delar +/// (personnummer, viktfördelning, organisationsträd) — kräver ingen databas. +/// +public class DemoBefolkningsGeneratorTests +{ + private static readonly DateOnly Idag = new(2026, 7, 3); + + [Fact] + public void PersonnummerGenerator_producerar_giltiga_unika_konskonsekventa_nummer() + { + var gen = new PersonnummerGenerator(new Random(12345)); + var sedda = new HashSet(); + + for (var i = 0; i < 5000; i++) + { + var arKvinna = i % 2 == 0; + var alder = 20 + i % 48; // 20..67 + + var pnr = gen.NastaUnikt(alder, arKvinna, Idag); + var tolv = (string)pnr; + + // Format: exakt 12 siffror och kan re-parsas av värdesobjektet (Luhn + datum). + Assert.Equal(12, tolv.Length); + _ = new Personnummer(tolv); // kastar om ogiltigt + + // Unikhet. + Assert.True(sedda.Add(tolv), $"Dubblett genererad: {tolv}"); + + // Kön konsekvent med begärt kön. + Assert.Equal(arKvinna ? "Kvinna" : "Man", pnr.LegalGender); + + // Ålder inom demo-intervallet. + Assert.Equal(alder, Idag.Year - pnr.BirthDate.Year); + } + + Assert.Equal(5000, gen.AntalGenererade); + } + + [Fact] + public void BeraknaLuhnKontrollsiffra_ger_giltigt_personnummer() + { + // YYMMDDNNN för 1985-03-15 + födelsenummer 238. + var kontroll = PersonnummerGenerator.BeraknaLuhnKontrollsiffra("850315238"); + + Assert.Equal(4, kontroll); + _ = new Personnummer("19850315238" + kontroll); // kastar om Luhn inte stämmer + } + + [Fact] + public void Reserverade_personnummer_ateranvands_aldrig() + { + const string reserverat = "198503152384"; // motsvarar en handplockad demo-användare + var gen = new PersonnummerGenerator(new Random(1), [reserverat]); + + for (var i = 0; i < 2000; i++) + { + var pnr = gen.NastaUnikt(30 + i % 30, i % 2 == 0, Idag); + Assert.NotEqual(reserverat, (string)pnr); + } + } + + [Fact] + public void BeraknaFordelning_summerar_exakt_till_antal() + { + var (_, bemanningsbara) = DemoOrganisation.Bygg(OrganizationId.New(), OrganizationId.New()); + + var fordelning = DemoBefolkningsGenerator.BeraknaFordelning(bemanningsbara, 11_000); + + Assert.Equal(bemanningsbara.Count, fordelning.Length); + Assert.Equal(11_000, fordelning.Sum()); + Assert.All(fordelning, n => Assert.True(n >= 0)); + } + + [Fact] + public void DemoOrganisation_bygger_realistiskt_trad_med_unika_kostnadsstallen() + { + var (enheter, bemanningsbara) = DemoOrganisation.Bygg(OrganizationId.New(), OrganizationId.New()); + + Assert.True(enheter.Count > 100, $"Förväntade >100 enheter, fick {enheter.Count}"); + Assert.True(bemanningsbara.Count > 80, $"Förväntade >80 bemanningsbara, fick {bemanningsbara.Count}"); + + // Kostnadsställen måste vara unika (inget krockar med varandra). + var ks = enheter.Select(e => e.Kostnadsstalle).ToList(); + Assert.Equal(ks.Count, ks.Distinct().Count()); + } +}