using AccountsDAL.CustomCode.AccountTree; using AccountsDAL.DTO.AccountGroup; using AccountsDAL.DTO.AccountSchedule; using AccountsDAL.DTO.AccountTree; using GB5Shared.DTO.Framework.Login; using Newtonsoft.Json; namespace AccountsBLL.AccountTree { // Pure chart-of-accounts navigator (Type -> Schedule -> Group -> Account) — a standalone // CRUD-navigation tree, distinct from the Account Hierarchy Report (ReportViewer/grouped // grid). No figures involved. Extends AccountGroupBLL.BuildAccountGroupTree's proven // flat-list -> nested-tree pattern up to a synthetic Type root and down to Account leaves. public class AccountTreeBLL : IAccountTreeBLL { private readonly IAccountTreeDAL _AccountTreeDAL; public AccountTreeBLL(IAccountTreeDAL accountTreeDAL) { _AccountTreeDAL = accountTreeDAL; } // NodeType discriminator values (AccountTreeNodeDTO.NodeType) private const byte TYPE_NODE = 0; private const byte SCHEDULE_NODE = 1; private const byte GROUP_NODE = 2; private const byte ACCOUNT_NODE = 3; // Synthetic Type bucket ids (never persisted — display grouping only) private const int TYPE_ASSET = 0; private const int TYPE_LIABILITY = 1; private const int TYPE_INCOME = 2; private const int TYPE_EXPENSE = 3; private const int ROOT_PARENT_ID = -1; // ── Read ───────────────────────────────────────────────────────────── public async Task GetAccountTree(LoginDTO loginDTO, CancellationToken ct) { try { var scheduleListJson = await _AccountTreeDAL.GetAccountScheduleFlatList(loginDTO, ct).ConfigureAwait(false); var groupListJson = await _AccountTreeDAL.GetAccountGroupFlatList(loginDTO, ct).ConfigureAwait(false); var schedules = JsonConvert.DeserializeObject>(scheduleListJson) ?? new List(); var groups = JsonConvert.DeserializeObject>(groupListJson) ?? new List(); var tree = BuildAccountTree(schedules, groups); return JsonConvert.SerializeObject(tree); } catch (Exception) { throw; } } public async Task GetAccountTreeLeaves( int accountGroupId, int firstNumber, int maxResult, LoginDTO loginDTO, CancellationToken ct) { try { var accounts = await _AccountTreeDAL .GetAccountsByGroup(accountGroupId, firstNumber, maxResult, loginDTO, ct) .ConfigureAwait(false); var leaves = accounts.Select(a => new AccountTreeNodeDTO { NodeType = ACCOUNT_NODE, Id = a.Id, Code = a.Code, Name = a.Name, ParentId = accountGroupId, HasChildren = false, AccountType = a.AccountType }).ToList(); return JsonConvert.SerializeObject(leaves); } catch (Exception) { throw; } } // ── Tree assembly ──────────────────────────────────────────────────── // Keyed by (NodeType, Id) composite since Schedule ids and Group ids can collide as // plain ints — a single int-keyed dictionary (as AccountGroupBLL.BuildAccountGroupTree // uses for its Group-only tree) is not safe once two entity types share the same space. private static List BuildAccountTree( List schedules, List groups) { var lookup = new Dictionary<(byte NodeType, int Id), AccountTreeNodeDTO>(); var roots = new List(); // 4 synthetic Type roots — always present regardless of whether any schedule maps to them. var typeBuckets = new List { new() { NodeType = TYPE_NODE, Id = TYPE_ASSET, Code = "ASSET", Name = "Asset", ParentId = ROOT_PARENT_ID }, new() { NodeType = TYPE_NODE, Id = TYPE_LIABILITY, Code = "LIABILITY", Name = "Liability", ParentId = ROOT_PARENT_ID }, new() { NodeType = TYPE_NODE, Id = TYPE_INCOME, Code = "INCOME", Name = "Income", ParentId = ROOT_PARENT_ID }, new() { NodeType = TYPE_NODE, Id = TYPE_EXPENSE, Code = "EXPENSE", Name = "Expense", ParentId = ROOT_PARENT_ID }, }; foreach (var bucket in typeBuckets) { lookup[(bucket.NodeType, bucket.Id)] = bucket; roots.Add(bucket); } // Schedule nodes — a schedule with a real parent schedule keeps that parent; only a // ROOT schedule (PARENTID=-1) gets reparented under its synthetic Type bucket. foreach (var s in schedules) { bool isRootSchedule = s.ParentId == ROOT_PARENT_ID; var node = new AccountTreeNodeDTO { NodeType = SCHEDULE_NODE, Id = s.Id, Code = s.Code, Name = s.Name, ParentId = isRootSchedule ? MapNatureToTypeBucket(s.Nature) : s.ParentId }; lookup[(SCHEDULE_NODE, s.Id)] = node; } foreach (var s in schedules) { var node = lookup[(SCHEDULE_NODE, s.Id)]; if (s.ParentId == ROOT_PARENT_ID) Attach(lookup, roots, TYPE_NODE, node.ParentId, node); else Attach(lookup, roots, SCHEDULE_NODE, s.ParentId, node); } // Group nodes — always expandable (accounts are lazy-loaded separately, never pulled // here). A group with PARENTID=-1 is a top-level group nesting directly under its // owning Schedule; otherwise it nests under its real parent Group. // // Two-pass, same as the Schedule nodes above: GET_ACCOUNTGROUP_FLAT_LIST orders by // ACCOUNTGROUPNAME (alphabetical), NOT parent-before-child, so a single combined // create+attach pass would incorrectly strand a child group as a root whenever its // name sorts before its real parent's — build every node into the lookup first. foreach (var g in groups) { bool isTopLevelGroup = g.ParentId == ROOT_PARENT_ID; var node = new AccountTreeNodeDTO { NodeType = GROUP_NODE, Id = g.Id, Code = g.Code, Name = g.Name, ParentId = isTopLevelGroup ? g.AccountScheduleId : g.ParentId, HasChildren = true }; lookup[(GROUP_NODE, g.Id)] = node; } foreach (var g in groups) { var node = lookup[(GROUP_NODE, g.Id)]; if (g.ParentId == ROOT_PARENT_ID) Attach(lookup, roots, SCHEDULE_NODE, g.AccountScheduleId, node); else Attach(lookup, roots, GROUP_NODE, g.ParentId, node); } return roots; } // Attaches child under the (parentNodeType, parentId) lookup entry, marking the parent // expandable. Falls back to surfacing the node as a root when the parent key is missing // (a dangling FK) rather than silently dropping it — a data-integrity issue should be // visible in the tree, not hidden. private static void Attach( Dictionary<(byte NodeType, int Id), AccountTreeNodeDTO> lookup, List roots, byte parentNodeType, int parentId, AccountTreeNodeDTO child) { if (lookup.TryGetValue((parentNodeType, parentId), out var parent)) { parent.Children.Add(child); parent.HasChildren = true; } else { roots.Add(child); } } // MACCOUNTSCHEDULE.ACCOUNTSCHEDULENATURE: 0=Liability,1=Asset,2=DirectIncome, // 3=DirectExpense,4=IndirectIncome,5=IndirectExpense — bucketed into 4 display groups // ({2,4}->Income, {3,5}->Expense), synthetic and never persisted. private static int MapNatureToTypeBucket(byte nature) => nature switch { 0 => TYPE_LIABILITY, 1 => TYPE_ASSET, 2 => TYPE_INCOME, 3 => TYPE_EXPENSE, 4 => TYPE_INCOME, 5 => TYPE_EXPENSE, _ => TYPE_ASSET // defensive fallback for an unexpected nature value — keeps the // schedule visible in the tree rather than silently dropping it }; } }