From d179fd52018bd08eb5642a767d489efc9c53289d Mon Sep 17 00:00:00 2001 From: Jonas Millard Date: Fri, 3 Jul 2026 10:32:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(org):=20visa=20hela=20organisationstr?= =?UTF-8?q?=C3=A4det=20+=20enhetsdetaljsida=20med=20anst=C3=A4llda?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixar att /organisation bara visade ett fåtal avdelningar och att man inte kunde klicka sig fram till en enhet. - HamtaOrganisationAsync laddade bara roten + en nivå barn → bara ~9 förvaltningar syntes trots att DB har 134 enheter i 4 nivåer. Laddar nu ALLA enheter platt i en fråga och bygger hierarkin i minnet (ByggHierarki, testad) — alla 134 enheter nås. Trädet är expanderbart (region + förvaltning utfällt som standard), varje nod visar antal underenheter och antal anställda (upprullat per subträd). - Ny detaljsida /organisation/{id} (Detalj.razor): visar enhetens info (typ, kostnadsställe, CFAR, HSA-id, giltighet, överordnad enhet), direkta underenheter (klickbara), fördelning per befattning, och de anställda som jobbar i enheten i en PAGINERAD tabell (filtrerad på EnhetId i SQL, aldrig hela registret). Anställd-rader klickbara till /anstallda/{id}. - Behörighet: /organisation/{guid} ärver ChefHrAdmin via prefixmatch (anställd nekas, fail-closed bevaras) — policyn orörd. - Nytt test OrgTreeBuilderTests (5 fall: 4-nivåkoppling, dinglande förälder, flera rötter, uppräkning, självreferens). Bygge grönt, tester gröna. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114SipfUPpV6WRPh9jMRfUL --- .../Pages/Organisation/Detalj.razor | 235 ++++++++++++++++ .../Components/Pages/Organisation/Index.razor | 17 +- .../Pages/Organisation/OrgNode.razor | 77 +++++- src/Web/Services/AnstallningService.cs | 260 +++++++++++++++++- .../Web.Tests/Services/OrgTreeBuilderTests.cs | 146 ++++++++++ 5 files changed, 718 insertions(+), 17 deletions(-) create mode 100644 src/Web/Components/Pages/Organisation/Detalj.razor create mode 100644 tests/Web.Tests/Services/OrgTreeBuilderTests.cs diff --git a/src/Web/Components/Pages/Organisation/Detalj.razor b/src/Web/Components/Pages/Organisation/Detalj.razor new file mode 100644 index 0000000..5248265 --- /dev/null +++ b/src/Web/Components/Pages/Organisation/Detalj.razor @@ -0,0 +1,235 @@ +@page "/organisation/{Id:guid}" +@layout RegionHR.Web.Components.Layout.AdminLayout +@using RegionHR.SharedKernel.Domain +@using RegionHR.Web.Services +@inject RegionHR.Web.Services.AnstallningService Service +@inject NavigationManager Nav + +Organisationsenhet - RegionHR + + + Tillbaka till organisationen + + +@if (_error != null) +{ + @_error +} + +@if (_loading) +{ + +} +else if (_detalj is null) +{ + Enheten hittades inte. +} +else +{ +
+ @_detalj.Namn + +
+ @if (_detalj.OverordnadId is { } pid) + { + + Tillhör + @(_detalj.OverordnadNamn ?? "överordnad enhet") + + } + else + { + Rotenhet (ingen överordnad enhet) + } + + + + + Enhetsuppgifter + + + Typ@TypNamn(_detalj.Typ) + Kostnadsställe@_detalj.Kostnadsstalle + CFAR-kod@(_detalj.CFARKod ?? "—") + HSA-id@(_detalj.HsaId ?? "—") + + Giltighet + @_detalj.GiltigFran.ToString("yyyy-MM-dd") – @(_detalj.GiltigTill?.ToString("yyyy-MM-dd") ?? "tillsvidare") + + Direkta underenheter@_detalj.DirektaUnderenheter.Count + Antal aktiva anställda@_detalj.AntalAnstallda + + + + + + + + Anställda per befattning + @if (_befattningar.Count == 0) + { + Inga aktiva anställda direkt i denna enhet. + } + else + { +
+ @foreach (var b in _befattningar) + { +
+ @b.Befattning + @b.Antal +
+ } +
+ } +
+
+
+ + @if (_detalj.DirektaUnderenheter.Count > 0) + { + + Underenheter (@_detalj.DirektaUnderenheter.Count) + + + NamnTypAntal anställda + + + @foreach (var barn in _detalj.DirektaUnderenheter) + { + + @barn.Namn + @TypNamn(barn.Typ) + @barn.AntalAnstallda + + Öppna + + + } + + + + } + + + Anställda i enheten + + Visar personer med en aktiv anställning direkt i @_detalj.Namn. Klicka på en rad för att öppna den anställde. + + + + Namn + Befattning + Sysselsättningsgrad + Anställningsform + + + + @context.Fornamn @context.Efternamn + @context.Befattning + @context.Sysselsattningsgrad% + @context.Anstallningsform + + Öppna + + + + Inga anställda i denna enhet. + + + + + + + + + +} + +@code { + [Parameter] public Guid Id { get; set; } + + private OrgEnhetDetalj? _detalj; + private List _befattningar = []; + private MudTable _table = null!; + private bool _loading = true; + private string? _error; + + protected override async Task OnParametersSetAsync() + { + _loading = true; + _error = null; + try + { + _detalj = await Service.HamtaEnhetDetaljAsync(Id); + _befattningar = _detalj is null ? [] : await Service.HamtaBefattningsfordelningAsync(Id); + } + catch (Exception ex) + { + _error = $"Kunde inte hämta enheten: {ex.Message}"; + } + finally + { + _loading = false; + } + + // Om användaren navigerar mellan enheter (samma sidkomponent) ska tabellen läsas om. + if (_table is not null) + await _table.ReloadServerData(); + } + + /// + /// Serversidig paginering av enhetens anställda: filtrerar på EnhetId i SQL och räknar + /// totalen med CountAsync i DB (via ) — laddar + /// aldrig hela registret, bara en sida (25/50/100). + /// + private async Task> LaddaAnstallda(TableState state, CancellationToken token) + { + try + { + var sida = await Service.HamtaSidaAsync( + state.Page * state.PageSize, + state.PageSize, + sokterm: null, + enhetId: Id, + ct: token); + return new TableData { Items = sida.Items, TotalItems = sida.TotalAntal }; + } + catch + { + return new TableData { Items = [], TotalItems = 0 }; + } + } + + private void OppnaRad(TableRowClickEventArgs args) + { + if (args.Item is { } rad) + Nav.NavigateTo($"/anstallda/{rad.Id.Value}"); + } + + private void Oppna(Guid enhetId) => Nav.NavigateTo($"/organisation/{enhetId}"); + + private static string TypNamn(OrganizationUnitType t) => t switch + { + OrganizationUnitType.Region => "Region", + OrganizationUnitType.Forvaltning => "Förvaltning", + OrganizationUnitType.Verksamhet => "Verksamhet", + OrganizationUnitType.Enhet => "Enhet", + OrganizationUnitType.Avdelning => "Avdelning", + OrganizationUnitType.Sektion => "Sektion", + _ => t.ToString() + }; + + private static string TypVariant(OrganizationUnitType t) => t switch + { + OrganizationUnitType.Region => "info", + OrganizationUnitType.Forvaltning => "info", + OrganizationUnitType.Verksamhet => "warning", + OrganizationUnitType.Enhet or OrganizationUnitType.Avdelning => "success", + _ => "info" + }; +} diff --git a/src/Web/Components/Pages/Organisation/Index.razor b/src/Web/Components/Pages/Organisation/Index.razor index 885aede..62ef86b 100644 --- a/src/Web/Components/Pages/Organisation/Index.razor +++ b/src/Web/Components/Pages/Organisation/Index.razor @@ -81,16 +81,20 @@ } else { + + @_antalEnheter enheter i @_rotenheter.Count @(_rotenheter.Count == 1 ? "rot" : "rötter"). Klicka på ett namn för att öppna enheten, eller på pilen för att fälla ut. + @foreach (var enhet in _rotenheter) { - + } } @code { - private List _rotenheter = []; + private List _rotenheter = []; + private int _antalEnheter; private List _plattaEnheter = []; private bool _loading = true; private bool _visarForm; @@ -113,6 +117,7 @@ try { _rotenheter = await Service.HamtaOrganisationAsync(); + _antalEnheter = RaknaNoder(_rotenheter); _plattaEnheter = await Service.HamtaEnheterAsync(); } catch (Exception ex) @@ -154,6 +159,14 @@ } } + private static int RaknaNoder(IEnumerable noder) + { + var summa = 0; + foreach (var n in noder) + summa += 1 + RaknaNoder(n.Barn); + return summa; + } + private static string TypNamn(OrganizationUnitType t) => t switch { OrganizationUnitType.Region => "Region", diff --git a/src/Web/Components/Pages/Organisation/OrgNode.razor b/src/Web/Components/Pages/Organisation/OrgNode.razor index 4e6746f..6f877f7 100644 --- a/src/Web/Components/Pages/Organisation/OrgNode.razor +++ b/src/Web/Components/Pages/Organisation/OrgNode.razor @@ -1,26 +1,81 @@ -@using RegionHR.Core.Domain +@using RegionHR.SharedKernel.Domain +@using RegionHR.Web.Services +@inject NavigationManager Nav -
0 ? "1rem" : "0");"> -
- - @Enhet.Namn - (@Enhet.Kostnadsstalle) +
0 ? "0.75rem" : "0");"> +
+ @if (Nod.AntalUnderenheter > 0) + { + + } + else + { + + } + + + + @Nod.Namn + + (@Nod.Kostnadsstalle) + + @if (Nod.AntalUnderenheter > 0) + { + · @Nod.AntalUnderenheter @(Nod.AntalUnderenheter == 1 ? "underenhet" : "underenheter") + } + @if (Nod.AntalAnstalldaTotalt > 0) + { + · @Nod.AntalAnstalldaTotalt anställda + }
- @if (Enhet.Underenheter.Count > 0) + @if (_expanded && Nod.Barn.Count > 0) { - @foreach (var child in Enhet.Underenheter) + @foreach (var barn in Nod.Barn) { - + } }
@code { - [Parameter] public OrganizationUnit Enhet { get; set; } = default!; + [Parameter] public OrgTreeNode Nod { get; set; } = default!; [Parameter] public int Level { get; set; } - private string TypVariant => Enhet.Typ switch + private bool _expanded; + + // Region (0) och förvaltning (1) fälls ut som standard, så de ~110 verksamhetsnivå- + // enheterna syns direkt; djupare nivåer expanderas på klick. + protected override void OnInitialized() => _expanded = Level <= 1; + + private void Toggle() => _expanded = !_expanded; + + private void Oppna() => Nav.NavigateTo($"/organisation/{Nod.Id.Value}"); + + private void OppnaViaTangent(KeyboardEventArgs e) + { + if (e.Key is "Enter" or " ") + Oppna(); + } + + private static string TypNamn(OrganizationUnitType t) => t switch + { + OrganizationUnitType.Region => "Region", + OrganizationUnitType.Forvaltning => "Förvaltning", + OrganizationUnitType.Verksamhet => "Verksamhet", + OrganizationUnitType.Enhet => "Enhet", + OrganizationUnitType.Avdelning => "Avdelning", + OrganizationUnitType.Sektion => "Sektion", + _ => t.ToString() + }; + + private static string TypVariant(OrganizationUnitType t) => t switch { OrganizationUnitType.Region => "info", OrganizationUnitType.Forvaltning => "info", diff --git a/src/Web/Services/AnstallningService.cs b/src/Web/Services/AnstallningService.cs index 2a51f90..637d23c 100644 --- a/src/Web/Services/AnstallningService.cs +++ b/src/Web/Services/AnstallningService.cs @@ -196,13 +196,200 @@ public async Task UppdateraSkatteuppgifterAsync( await db.SaveChangesAsync(ct); } - public async Task> HamtaOrganisationAsync(CancellationToken ct = default) + /// + /// Hämtar HELA organisationsträdet i EN platt fråga och bygger parent→barn-hierarkin + /// i minnet (via ). Den gamla varianten + /// laddade bara roten + en nivå barn med .Include(o => o.Underenheter), så nivå + /// 3–4 (kliniker/vårdcentraler/underenheter) blev osynliga. Nu returneras rot-noderna + /// med fullt påfyllda barn i alla nivåer — alla enheter är nåbara. Varje nod bär även + /// antal aktiva anställda (direkt + rullat upp över subträdet) för att kunna visas i UI. + /// + public async Task> HamtaOrganisationAsync(CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + // En platt fråga över hela (lilla) enhetstabellen, ordnad på namn. + var enheter = await db.OrganizationUnits + .AsNoTracking() + .OrderBy(o => o.Namn) + .ToListAsync(ct); + + var antalPerEnhet = await HamtaAntalAnstalldaPerEnhetAsync(db, ct); + + var platta = enheter.Select(o => new OrgTreeNode( + o.Id, + o.Namn, + o.Typ, + o.Kostnadsstalle, + o.OverordnadEnhetId is { } p ? p.Value : (Guid?)null, + antalPerEnhet.TryGetValue(o.Id.Value, out var n) ? n : 0)); + + return ByggHierarki(platta); + } + + /// + /// Rent in-minne-trädbygge: kopplar en platt lista av noder till en parent→barn-hierarki + /// via och returnerar rot-noderna. Barnens ordning + /// bevaras från inlistan (kalla med namnsorterad lista för namnsorterat träd). En nod utan + /// förälder — ELLER med en förälder som inte finns i urvalet (dinglande FK) — behandlas som + /// rot, så att ingen enhet någonsin blir onåbar. Antal anställda rullas upp per subträd. + /// Ren funktion utan DB-beroende → enhetstestbar. + /// + public static List ByggHierarki(IEnumerable platta) + { + var noder = platta as IList ?? platta.ToList(); + + var perId = new Dictionary(noder.Count); + foreach (var n in noder) + { + n.Barn.Clear(); + perId[n.Id.Value] = n; // vid ev. dubbletter vinner den sista + } + + var rotter = new List(); + foreach (var n in noder) + { + if (n.OverordnadId is { } pid + && perId.TryGetValue(pid, out var parent) + && !ReferenceEquals(parent, n)) + { + parent.Barn.Add(n); + } + else + { + rotter.Add(n); + } + } + + foreach (var rot in rotter) + BeraknaTotaltAntal(rot); + + return rotter; + } + + private static int BeraknaTotaltAntal(OrgTreeNode nod) + { + var summa = nod.AntalAnstalldaDirekt; + foreach (var barn in nod.Barn) + summa += BeraknaTotaltAntal(barn); + nod.AntalAnstalldaTotalt = summa; + return summa; + } + + /// + /// Antal AKTIVA anställda per enhet (nyckel = enhetens Guid). Hämtar bara enhets-id-kolumnen + /// för aktiva anställningar i en fråga och grupperar i minnet — undviker att materialisera + /// hela anställningsraderna och är oberoende av GroupBy-översättning för värde-konverterade + /// nycklar. Enhetsurvalet är litet; anställningarna filtreras på giltighetsperioden i SQL. + /// + /// + /// Om satt: räkna bara dessa enheter (IN-filter i SQL) i stället för hela registret. Används + /// av enhetsdetaljen som bara behöver enheten själv + dess direkta barn — då transporteras + /// bara de matchande radernas enhets-id, inte alla ~11 000. Trädvyn kallar utan filter. + /// + private static async Task> HamtaAntalAnstalldaPerEnhetAsync( + RegionHRDbContext db, CancellationToken ct, IReadOnlyCollection? begransaTill = null) + { + var idag = DateOnly.FromDateTime(DateTime.Today); + var query = db.Employments + .Where(a => a.Giltighetsperiod.Start <= idag + && (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag)); + + if (begransaTill is { Count: > 0 }) + query = query.Where(a => begransaTill.Contains(a.EnhetId)); + + var enhetsIds = await query + .Select(a => a.EnhetId) + .ToListAsync(ct); + + return enhetsIds + .GroupBy(id => id.Value) + .ToDictionary(g => g.Key, g => g.Count()); + } + + /// + /// Detaljdata för EN organisationsenhet: enhetens egna fält, dess överordnade enhet + /// (för länk tillbaka) och dess DIREKTA underenheter (med antal aktiva anställda per barn). + /// Antal anställda i själva enheten räknas i DB. Laddar aldrig anställningsrader hit — den + /// paginerade anställdlistan hämtas separat via . + /// + public async Task HamtaEnhetDetaljAsync(Guid id, CancellationToken ct = default) { await using var db = await _dbFactory.CreateDbContextAsync(ct); - return await db.OrganizationUnits - .Include(o => o.Underenheter) - .Where(o => o.OverordnadEnhetId == null) + var oid = OrganizationId.From(id); + + var enhet = await db.OrganizationUnits + .AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == oid, ct); + if (enhet is null) + return null; + + string? overordnadNamn = null; + if (enhet.OverordnadEnhetId is { } pid) + { + overordnadNamn = await db.OrganizationUnits + .AsNoTracking() + .Where(o => o.Id == pid) + .Select(o => o.Namn) + .FirstOrDefaultAsync(ct); + } + + var barn = await db.OrganizationUnits + .AsNoTracking() + .Where(o => o.OverordnadEnhetId == oid) + .OrderBy(o => o.Namn) .ToListAsync(ct); + + // Räkna anställda bara för enheten själv + dess direkta barn (inte hela registret). + var relevanta = new List(barn.Count + 1) { enhet.Id }; + relevanta.AddRange(barn.Select(b => b.Id)); + var antalPerEnhet = await HamtaAntalAnstalldaPerEnhetAsync(db, ct, relevanta); + int Antal(OrganizationId x) => antalPerEnhet.TryGetValue(x.Value, out var n) ? n : 0; + + var barnDto = barn + .Select(b => new OrgEnhetBarn(b.Id, b.Namn, b.Typ, Antal(b.Id))) + .ToList(); + + return new OrgEnhetDetalj( + enhet.Id, + enhet.Namn, + enhet.Typ, + enhet.Kostnadsstalle, + enhet.CFARKod, + enhet.HsaId, + enhet.Giltighet.Start, + enhet.Giltighet.End, + enhet.OverordnadEnhetId is { } op ? op : (OrganizationId?)null, + overordnadNamn, + barnDto, + Antal(enhet.Id)); + } + + /// + /// Sammanfattning "antal aktiva anställda per befattning" för en enhet. Aggregeras i DB + /// (bara befattning + antal transporteras) och filtreras på enhet + aktiv giltighetsperiod. + /// + public async Task> HamtaBefattningsfordelningAsync( + Guid enhetId, CancellationToken ct = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + var oid = OrganizationId.From(enhetId); + var idag = DateOnly.FromDateTime(DateTime.Today); + + // Hämtar bara befattningskolumnen för enhetens aktiva anställningar; grupperar i minnet. + var titlar = await db.Employments + .Where(a => a.EnhetId == oid + && a.Giltighetsperiod.Start <= idag + && (a.Giltighetsperiod.End == null || a.Giltighetsperiod.End >= idag)) + .Select(a => a.Befattningstitel) + .ToListAsync(ct); + + return titlar + .GroupBy(t => string.IsNullOrWhiteSpace(t) ? "Ej angiven" : t!.Trim()) + .Select(g => new BefattningsAntal(g.Key, g.Count())) + .OrderByDescending(r => r.Antal) + .ThenBy(r => r.Befattning, StringComparer.CurrentCultureIgnoreCase) + .ToList(); } /// Platt lista över alla enheter för val i anställningsformulär. @@ -348,3 +535,68 @@ public record PagedResult(IReadOnlyList Items, int TotalAntal); public record EnhetVal(OrganizationId Id, string Namn, string Kostnadsstalle); public record AvtalVal(CollectiveAgreementId Id, string Namn); + +/// +/// En nod i organisationsträdet (vy-modell). Bär enhetens visningsfält, förälder-id (för +/// hierarkibygget), antal aktiva anställda direkt i enheten och — efter — det totala antalet i hela subträdet samt +/// barnnoderna. Muterbar där det behövs (Barn/summor fylls under trädbygget); allt annat är +/// oföränderligt efter konstruktion. +/// +public sealed class OrgTreeNode +{ + public OrganizationId Id { get; } + public string Namn { get; } + public OrganizationUnitType Typ { get; } + public string Kostnadsstalle { get; } + public Guid? OverordnadId { get; } + + /// Antal aktiva anställda direkt i denna enhet (exkl. underenheter). + public int AntalAnstalldaDirekt { get; set; } + + /// Antal aktiva anställda i hela subträdet (inkl. denna enhet). Sätts av trädbygget. + public int AntalAnstalldaTotalt { get; set; } + + public List Barn { get; } = []; + + /// Antal direkta underenheter. + public int AntalUnderenheter => Barn.Count; + + public OrgTreeNode( + OrganizationId id, + string namn, + OrganizationUnitType typ, + string kostnadsstalle, + Guid? overordnadId, + int antalAnstalldaDirekt = 0) + { + Id = id; + Namn = namn; + Typ = typ; + Kostnadsstalle = kostnadsstalle; + OverordnadId = overordnadId; + AntalAnstalldaDirekt = antalAnstalldaDirekt; + AntalAnstalldaTotalt = antalAnstalldaDirekt; + } +} + +/// Detaljvy för en organisationsenhet (enhetsfält + förälder + direkta barn). +public record OrgEnhetDetalj( + OrganizationId Id, + string Namn, + OrganizationUnitType Typ, + string Kostnadsstalle, + string? CFARKod, + string? HsaId, + DateOnly GiltigFran, + DateOnly? GiltigTill, + OrganizationId? OverordnadId, + string? OverordnadNamn, + IReadOnlyList DirektaUnderenheter, + int AntalAnstallda); + +/// En direkt underenhet i detaljvyn (klickbar, med antal anställda). +public record OrgEnhetBarn(OrganizationId Id, string Namn, OrganizationUnitType Typ, int AntalAnstallda); + +/// Rad i sammanfattningen "antal anställda per befattning". +public record BefattningsAntal(string Befattning, int Antal); diff --git a/tests/Web.Tests/Services/OrgTreeBuilderTests.cs b/tests/Web.Tests/Services/OrgTreeBuilderTests.cs new file mode 100644 index 0000000..53d795e --- /dev/null +++ b/tests/Web.Tests/Services/OrgTreeBuilderTests.cs @@ -0,0 +1,146 @@ +using RegionHR.SharedKernel.Domain; +using RegionHR.Web.Services; + +namespace RegionHR.Web.Tests.Services; + +/// +/// Enhetstest för det rena in-minne-trädbygget () +/// som ersatte den gamla ".Include(Underenheter).Where(root)"-frågan (den laddade bara roten +/// + en nivå barn). Bygget måste koppla ALLA nivåer och aldrig tappa en enhet. +/// +public class OrgTreeBuilderTests +{ + private static OrgTreeNode Nod(Guid id, string namn, Guid? parent, int antalDirekt = 0) => + new(OrganizationId.From(id), namn, OrganizationUnitType.Enhet, "10", parent, antalDirekt); + + private static int RaknaNaabara(IEnumerable rotter) + { + var summa = 0; + foreach (var n in rotter) + summa += 1 + RaknaNaabara(n.Barn); + return summa; + } + + [Fact] + public void ByggHierarki_KopplarAllaFyraNivaer() + { + var region = Guid.NewGuid(); + var forvaltning = Guid.NewGuid(); + var klinik = Guid.NewGuid(); + var underenhet = Guid.NewGuid(); + + var platta = new[] + { + Nod(region, "Region Örebro län", null), + Nod(forvaltning, "Universitetssjukhuset", region), + Nod(klinik, "Kardiologkliniken", forvaltning), + Nod(underenhet, "Avdelning 32", klinik), + }; + + var rotter = AnstallningService.ByggHierarki(platta); + + // Exakt EN rot, och hela kedjan region → förvaltning → klinik → underenhet är nåbar. + var rot = Assert.Single(rotter); + Assert.Equal("Region Örebro län", rot.Namn); + + var f = Assert.Single(rot.Barn); + Assert.Equal("Universitetssjukhuset", f.Namn); + + var k = Assert.Single(f.Barn); + Assert.Equal("Kardiologkliniken", k.Namn); + + var u = Assert.Single(k.Barn); + Assert.Equal("Avdelning 32", u.Namn); + + // Ingen enhet tappas: alla 4 är nåbara från roten. + Assert.Equal(4, RaknaNaabara(rotter)); + } + + [Fact] + public void ByggHierarki_BehandlarDinglandeForalderSomRot_SaIngenTappas() + { + var saknadForalder = Guid.NewGuid(); // finns INTE i urvalet + var barn = Guid.NewGuid(); + + var platta = new[] + { + Nod(barn, "Föräldralös enhet", saknadForalder), + }; + + var rotter = AnstallningService.ByggHierarki(platta); + + var rot = Assert.Single(rotter); + Assert.Equal("Föräldralös enhet", rot.Namn); + Assert.Equal(1, RaknaNaabara(rotter)); + } + + [Fact] + public void ByggHierarki_StoderFleraRotter() + { + var r1 = Guid.NewGuid(); + var r2 = Guid.NewGuid(); + var barnTillR1 = Guid.NewGuid(); + + var platta = new[] + { + Nod(r1, "Rot A", null), + Nod(r2, "Rot B", null), + Nod(barnTillR1, "Barn under A", r1), + }; + + var rotter = AnstallningService.ByggHierarki(platta); + + Assert.Equal(2, rotter.Count); + Assert.Equal(3, RaknaNaabara(rotter)); + var rotA = Assert.Single(rotter, x => x.Namn == "Rot A"); + Assert.Single(rotA.Barn); + Assert.Empty(Assert.Single(rotter, x => x.Namn == "Rot B").Barn); + } + + [Fact] + public void ByggHierarki_RullarUppAntalAnstalldaOverSubtradet() + { + var region = Guid.NewGuid(); + var forvaltning = Guid.NewGuid(); + var klinikA = Guid.NewGuid(); + var klinikB = Guid.NewGuid(); + + var platta = new[] + { + Nod(region, "Region", null, antalDirekt: 0), + Nod(forvaltning, "Förvaltning", region, antalDirekt: 2), + Nod(klinikA, "Klinik A", forvaltning, antalDirekt: 5), + Nod(klinikB, "Klinik B", forvaltning, antalDirekt: 3), + }; + + var rotter = AnstallningService.ByggHierarki(platta); + var rot = Assert.Single(rotter); + + // Totalt = 0 + 2 + 5 + 3 = 10 i hela subträdet; direkt i roten = 0. + Assert.Equal(10, rot.AntalAnstalldaTotalt); + Assert.Equal(0, rot.AntalAnstalldaDirekt); + + var f = Assert.Single(rot.Barn); + Assert.Equal(10, f.AntalAnstalldaTotalt); // 2 + 5 + 3 + Assert.Equal(2, f.AntalAnstalldaDirekt); + Assert.Equal(2, f.AntalUnderenheter); + + var kA = Assert.Single(f.Barn, x => x.Namn == "Klinik A"); + Assert.Equal(5, kA.AntalAnstalldaTotalt); // löv → totalt == direkt + Assert.Equal(5, kA.AntalAnstalldaDirekt); + } + + [Fact] + public void ByggHierarki_IgnorererSjalvreferens_UtanAttTappaNoden() + { + var id = Guid.NewGuid(); + // En enhet som (felaktigt) pekar på sig själv får inte bli sitt eget barn eller försvinna. + var platta = new[] { Nod(id, "Självreferens", id) }; + + var rotter = AnstallningService.ByggHierarki(platta); + + var rot = Assert.Single(rotter); + Assert.Empty(rot.Barn); + Assert.Equal(1, RaknaNaabara(rotter)); + } +}