Distribution_GL Initialization
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
using General_Ledger_Service.DTOs.Accounts;
|
||||
using General_Ledger_Service.DTOs.Reports;
|
||||
using General_Ledger_Service.Infra;
|
||||
using General_Ledger_Service.Interfaces.Repositories;
|
||||
using General_Ledger_Service.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace General_Ledger_Service.Repositories
|
||||
{
|
||||
public class AccountRepository(AppDBContext context) : IAccountRepository
|
||||
{
|
||||
private readonly AppDBContext _context = context;
|
||||
|
||||
public Task<Account?> GetByIdAsync(long accountId) =>
|
||||
RepositoryHelpers.GetByIdAsync(_context.Accounts, accountId);
|
||||
|
||||
public async Task<Account?> GetByCodeAsync(string accountCode) =>
|
||||
await _context.Accounts.FirstOrDefaultAsync(a => a.AccountCode == accountCode);
|
||||
|
||||
// Used by BankService's auto-created "Bank"/"Cash" GL hierarchy (2026-07-31) to find an
|
||||
// existing system header/type node by its fixed name before creating a new one -
|
||||
// accountName is always a fixed literal here ("Bank", "Cash", a CashAccountType.Name),
|
||||
// never end-user free text, so a name collision would only ever come from the same
|
||||
// auto-creation logic itself, not an unrelated manually-created account.
|
||||
public async Task<Account?> GetByNameAndParentAsync(string accountName, long? parentAccountId) =>
|
||||
await _context.Accounts.FirstOrDefaultAsync(a => a.AccountName == accountName && a.ParentAccountId == parentAccountId);
|
||||
|
||||
public async Task<int> GetAncestorDepthAsync(long parentAccountId) =>
|
||||
RecursiveTreeQueries.GetAncestorDepth(await LoadTreeRowsAsync(), parentAccountId);
|
||||
|
||||
public async Task<string?> GetAncestorBreadcrumbAsync(long accountId) =>
|
||||
RecursiveTreeQueries.GetAncestorBreadcrumb(await LoadTreeRowsAsync(), accountId);
|
||||
|
||||
public async Task<List<AccountTreeNode>> GetDescendantTreeAsync(long accountId)
|
||||
{
|
||||
var rows = RecursiveTreeQueries.GetDescendantTree(await LoadTreeRowsAsync(), accountId);
|
||||
return [.. rows.Select(r => new AccountTreeNode
|
||||
{
|
||||
AccountId = r.Id,
|
||||
AccountCode = r.Code,
|
||||
AccountName = r.Name,
|
||||
ParentAccountId = r.ParentId,
|
||||
Depth = r.Depth
|
||||
})];
|
||||
}
|
||||
|
||||
public async Task<bool> WouldCreateCycleAsync(long accountId, long newParentId) =>
|
||||
RecursiveTreeQueries.WouldCreateCycle(await LoadTreeRowsAsync(), accountId, newParentId);
|
||||
|
||||
public async Task<(List<Account> Items, int TotalCount)> SearchAsync(short? accountTypeId, bool? isActive, bool? isPostable, int? page, int? pageSize)
|
||||
{
|
||||
var query = _context.Accounts.AsQueryable();
|
||||
|
||||
if (accountTypeId.HasValue)
|
||||
{
|
||||
query = query.Where(a => a.AccountTypeId == accountTypeId.Value);
|
||||
}
|
||||
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(a => a.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
if (isPostable.HasValue)
|
||||
{
|
||||
query = query.Where(a => a.IsPostable == isPostable.Value);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await RepositoryHelpers.ApplyPaging(query.OrderBy(a => a.AccountCode), page, pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public Task<Account> AddAsync(Account account) =>
|
||||
RepositoryHelpers.AddAsync(_context.Accounts, account);
|
||||
|
||||
|
||||
// Retrofit 2026-07-22 - flat list, no depth/indentation (05_LKAS_Report_PDF_Templates.md
|
||||
// Section 2): a plain postable-leaf-account listing needs no recursive tree at all.
|
||||
// Fix 2026-10-20: each row shows the account's NET balance in exactly one column (debit
|
||||
// balance -> Debit, credit balance -> Credit, the other stays 0 and renders blank), and an
|
||||
// account whose debits and credits cancel out (net zero) is left out entirely - per
|
||||
// 05_LKAS_Report_PDF_Templates.md Section 2. Previously this returned the gross debit and
|
||||
// credit totals side by side, so any account with movement on both sides (or an entry and
|
||||
// its reversal) showed equal/duplicated figures in both columns.
|
||||
public async Task<List<TrialBalanceRowDto>> GetTrialBalanceAsync(DateOnly asOfDate)
|
||||
{
|
||||
var totals = await GetPostedTotalsAsync(null, asOfDate);
|
||||
var postableAccounts = await _context.Accounts.AsNoTracking().Where(a => a.IsPostable).ToListAsync();
|
||||
|
||||
return [.. postableAccounts
|
||||
.Where(a => totals.TryGetValue(a.AccountId, out var t) && t.Debit != t.Credit)
|
||||
.OrderBy(a => a.AccountCode, StringComparer.Ordinal)
|
||||
.Select(a =>
|
||||
{
|
||||
var net = totals[a.AccountId].Debit - totals[a.AccountId].Credit;
|
||||
return new TrialBalanceRowDto
|
||||
{
|
||||
AccountCode = a.AccountCode,
|
||||
AccountName = a.AccountName,
|
||||
Debit = net > 0 ? net : 0,
|
||||
Credit = net < 0 ? -net : 0
|
||||
};
|
||||
})];
|
||||
}
|
||||
|
||||
// Retrofit 2026-07-31 - Accounting_System_Design_LKAS.md Section 11.3. Leaf/postable rows
|
||||
// only, tagged with balance_sheet_classification inherited down the tree (same
|
||||
// recursive-tag pattern as cash_flow_category/income_statement_section); ReportService
|
||||
// groups these into the nested BalanceSheetReportDto (Non-Current/Current Assets,
|
||||
// Equity, Non-Current/Current Liabilities) and computes section/grand totals. Balance is
|
||||
// already sign-adjusted per account type: Asset is debit-normal (debit - credit);
|
||||
// Liability/Equity are credit-normal, so they use the opposite sign (credit - debit) to
|
||||
// show as conventional positive balances too - the same flip the P&L applies.
|
||||
public async Task<List<BalanceSheetLeafRowDto>> GetBalanceSheetLeafRowsAsync(DateOnly asOfDate)
|
||||
{
|
||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
||||
var roots = accounts.Where(a => a.ParentAccountId == null && (a.AccountTypeId == 1 || a.AccountTypeId == 2 || a.AccountTypeId == 3));
|
||||
var classification = InheritTag(accounts, roots, a => a.BalanceSheetClassification);
|
||||
var totals = await GetPostedTotalsAsync(null, asOfDate);
|
||||
|
||||
return [.. accounts
|
||||
.Where(a => a.IsPostable && classification.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
|
||||
.Select(a => new BalanceSheetLeafRowDto
|
||||
{
|
||||
AccountCode = a.AccountCode,
|
||||
AccountName = a.AccountName,
|
||||
AccountTypeId = a.AccountTypeId,
|
||||
BalanceSheetClassification = classification[a.AccountId],
|
||||
Balance = SignedBalance(a.AccountTypeId, totals[a.AccountId])
|
||||
})
|
||||
.Where(r => r.Balance != 0)
|
||||
.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
// Retrofit 2026-07-22 - Accounting_System_Design_LKAS.md Section 11.4. Leaf/postable rows
|
||||
// only, tagged with income_statement_section inherited down the tree (same recursive-tag
|
||||
// pattern as cash_flow_category); ReportService groups these into the nested
|
||||
// ProfitAndLossReportDto and applies the Expense-side sign flip (Amount here is the raw,
|
||||
// unflipped credit - debit - negative for a normal debit-balance Expense account).
|
||||
public async Task<List<ProfitAndLossLeafRowDto>> GetProfitAndLossLeafRowsAsync(DateOnly periodStart, DateOnly periodEnd)
|
||||
{
|
||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
||||
var roots = accounts.Where(a => a.ParentAccountId == null && (a.AccountTypeId == 4 || a.AccountTypeId == 5));
|
||||
var section = InheritTag(accounts, roots, a => a.IncomeStatementSection);
|
||||
var totals = await GetPostedTotalsAsync(periodStart, periodEnd);
|
||||
|
||||
return [.. accounts
|
||||
.Where(a => a.IsPostable && section.ContainsKey(a.AccountId) && totals.ContainsKey(a.AccountId))
|
||||
.Select(a => new ProfitAndLossLeafRowDto
|
||||
{
|
||||
AccountCode = a.AccountCode,
|
||||
AccountName = a.AccountName,
|
||||
AccountTypeId = a.AccountTypeId,
|
||||
IncomeStatementSection = section[a.AccountId],
|
||||
Amount = totals[a.AccountId].Credit - totals[a.AccountId].Debit
|
||||
})
|
||||
.Where(r => r.Amount != 0)
|
||||
.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
// Accounting_System_Design_LKAS.md Section 11.5 - "cash-like" accounts are the recursive
|
||||
// descendants of the configured CASH_ACCOUNT_ROOT_ID system_config value (resolved and
|
||||
// validated by ReportService before this is ever called - a plain long here, so an
|
||||
// unset/invalid config fails with a clear error before reaching any query).
|
||||
public async Task<List<CashFlowWorkingCapitalChangeDto>> GetCashFlowWorkingCapitalChangesAsync(DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId)
|
||||
{
|
||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
||||
var cashAccountIds = RecursiveTreeQueries.GetDescendantIds(ToTreeRows(accounts), cashAccountRootId);
|
||||
var category = InheritTag(accounts, accounts.Where(a => a.ParentAccountId == null), a => a.CashFlowCategory);
|
||||
var opening = await GetPostedTotalsAsync(null, periodStart.AddDays(-1));
|
||||
var closing = await GetPostedTotalsAsync(null, periodEnd);
|
||||
|
||||
var rows = new List<CashFlowWorkingCapitalChangeDto>();
|
||||
foreach (var a in accounts.Where(a => a.IsPostable
|
||||
&& !cashAccountIds.Contains(a.AccountId)
|
||||
&& category.GetValueOrDefault(a.AccountId) == "OPERATING"))
|
||||
{
|
||||
var openingBalance = opening.TryGetValue(a.AccountId, out var o) ? SignedBalance(a.AccountTypeId, o) : 0m;
|
||||
var closingBalance = closing.TryGetValue(a.AccountId, out var c) ? SignedBalance(a.AccountTypeId, c) : 0m;
|
||||
var change = closingBalance - openingBalance;
|
||||
if (change == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new CashFlowWorkingCapitalChangeDto
|
||||
{
|
||||
AccountCode = a.AccountCode,
|
||||
AccountName = a.AccountName,
|
||||
Direction = change >= 0 ? "Increase" : "Decrease",
|
||||
ChangeAmount = a.AccountTypeId == 1 ? -change : change
|
||||
});
|
||||
}
|
||||
|
||||
return [.. rows.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
public async Task<List<CashFlowLineDto>> GetCashFlowCategoryLinesAsync(string category, DateOnly periodStart, DateOnly periodEnd, long cashAccountRootId)
|
||||
{
|
||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
||||
var cashAccountIds = RecursiveTreeQueries.GetDescendantIds(ToTreeRows(accounts), cashAccountRootId);
|
||||
var inherited = InheritTag(accounts, accounts.Where(a => a.ParentAccountId == null), a => a.CashFlowCategory);
|
||||
var totals = await GetPostedTotalsAsync(periodStart, periodEnd);
|
||||
|
||||
return [.. accounts
|
||||
.Where(a => a.IsPostable
|
||||
&& !cashAccountIds.Contains(a.AccountId)
|
||||
&& inherited.GetValueOrDefault(a.AccountId) == category
|
||||
&& totals.ContainsKey(a.AccountId))
|
||||
.Select(a => new CashFlowLineDto
|
||||
{
|
||||
AccountCode = a.AccountCode,
|
||||
AccountName = a.AccountName,
|
||||
Amount = totals[a.AccountId].Credit - totals[a.AccountId].Debit
|
||||
})
|
||||
.OrderBy(r => r.AccountCode, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
public async Task<decimal> GetCashBalanceAsync(DateOnly asOfDate, long cashAccountRootId)
|
||||
{
|
||||
var accounts = await _context.Accounts.AsNoTracking().ToListAsync();
|
||||
var cashAccountIds = RecursiveTreeQueries.GetDescendantIds(ToTreeRows(accounts), cashAccountRootId);
|
||||
var totals = await GetPostedTotalsAsync(null, asOfDate);
|
||||
|
||||
return totals
|
||||
.Where(t => cashAccountIds.Contains(t.Key))
|
||||
.Sum(t => t.Value.Debit - t.Value.Credit);
|
||||
}
|
||||
|
||||
private async Task<List<RecursiveTreeQueries.TreeRow>> LoadTreeRowsAsync() =>
|
||||
await _context.Accounts.AsNoTracking()
|
||||
.Select(a => new RecursiveTreeQueries.TreeRow
|
||||
{
|
||||
Id = a.AccountId,
|
||||
Code = a.AccountCode,
|
||||
Name = a.AccountName,
|
||||
ParentId = a.ParentAccountId
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
private static List<RecursiveTreeQueries.TreeRow> ToTreeRows(IEnumerable<Account> accounts) =>
|
||||
[.. accounts.Select(a => new RecursiveTreeQueries.TreeRow
|
||||
{
|
||||
Id = a.AccountId,
|
||||
Code = a.AccountCode,
|
||||
Name = a.AccountName,
|
||||
ParentId = a.ParentAccountId
|
||||
})];
|
||||
|
||||
// Per-account base debit/credit totals over posted journal entries, optionally bounded
|
||||
// by entry date (inclusive on both ends). The sums run in the database; accounts with no
|
||||
// matching lines are simply absent from the result.
|
||||
private async Task<Dictionary<long, (decimal Debit, decimal Credit)>> GetPostedTotalsAsync(DateOnly? from, DateOnly? to)
|
||||
{
|
||||
var lines = _context.JournalEntryLines.Where(l => l.Journal.IsPosted);
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
lines = lines.Where(l => l.Journal.EntryDate >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
lines = lines.Where(l => l.Journal.EntryDate <= to.Value);
|
||||
}
|
||||
|
||||
var totals = await lines
|
||||
.GroupBy(l => l.AccountId)
|
||||
.Select(g => new { AccountId = g.Key, Debit = g.Sum(l => l.BaseDebit), Credit = g.Sum(l => l.BaseCredit) })
|
||||
.ToListAsync();
|
||||
|
||||
return totals.ToDictionary(t => t.AccountId, t => (t.Debit, t.Credit));
|
||||
}
|
||||
|
||||
// Asset (type 1) is debit-normal; every other type is credit-normal.
|
||||
private static decimal SignedBalance(short accountTypeId, (decimal Debit, decimal Credit) totals) =>
|
||||
accountTypeId == 1 ? totals.Debit - totals.Credit : totals.Credit - totals.Debit;
|
||||
|
||||
// Walks the tree from the given roots, giving each node its own tag or, if it has none,
|
||||
// its parent's inherited tag. Only nodes reachable from the roots appear in the result,
|
||||
// so a root filter (e.g. types 1-3 only) also scopes which accounts are included.
|
||||
private static Dictionary<long, string?> InheritTag(List<Account> accounts, IEnumerable<Account> roots, Func<Account, string?> tag)
|
||||
{
|
||||
var childrenByParent = accounts
|
||||
.Where(a => a.ParentAccountId.HasValue)
|
||||
.ToLookup(a => a.ParentAccountId!.Value);
|
||||
|
||||
var result = new Dictionary<long, string?>();
|
||||
var stack = new Stack<(Account Node, string? Inherited)>();
|
||||
foreach (var root in roots)
|
||||
{
|
||||
stack.Push((root, tag(root)));
|
||||
}
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var (node, inherited) = stack.Pop();
|
||||
if (!result.TryAdd(node.AccountId, inherited))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in childrenByParent[node.AccountId])
|
||||
{
|
||||
stack.Push((child, tag(child) ?? inherited));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user