Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca81e9bc42 | |||
| cc33ecdd54 | |||
| f77db46a02 | |||
| 267144f7a1 | |||
| efb5657b6b | |||
| 622b75e187 | |||
| 293df41d4e | |||
| 5e81fd77b1 | |||
| a1fd5f1a08 |
@@ -66,6 +66,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||
public DbSet<BankAccount> BankAccounts { get; set; }
|
||||
public DbSet<GlPostingLog> GlPostingLogs { get; set; }
|
||||
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
||||
public DbSet<ChequePage> ChequePages { get; set; }
|
||||
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||
@@ -582,6 +583,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasIndex(r => r.FK_RegionId)
|
||||
.IsUnique();
|
||||
|
||||
// --- GL posting log ---
|
||||
|
||||
modelBuilder.Entity<GlPostingLog>()
|
||||
.HasIndex(l => new { l.Event, l.SourceId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<GlPostingLog>()
|
||||
.Property(l => l.Event)
|
||||
.HasMaxLength(60);
|
||||
|
||||
// --- SysConfig ---
|
||||
|
||||
modelBuilder.Entity<SysConfig>()
|
||||
|
||||
@@ -68,6 +68,8 @@ public static class RoleAndPermissionSeed
|
||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||
private static readonly Guid PermFinanceReportView = new("20000000-0000-0000-0000-000000000051");
|
||||
private static readonly Guid PermGlManage = new("20000000-0000-0000-0000-000000000052");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -134,7 +136,9 @@ public static class RoleAndPermissionSeed
|
||||
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermFinanceReportView, Code = "finance-reports.view", Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermGlManage, Code = "gl.manage", Description = "Set up the General Ledger link and retry failed postings", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
// SuperAdmin gets every permission
|
||||
@@ -191,7 +195,9 @@ public static class RoleAndPermissionSeed
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -7,7 +8,8 @@ namespace Distribution.Features.ChequeBook;
|
||||
public class ChequeBookService(
|
||||
IChequeBookRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow)
|
||||
IUnitOfWork uow,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private const string DefaultCurrency = "LKR";
|
||||
|
||||
@@ -171,6 +173,7 @@ public class ChequeBookService(
|
||||
|
||||
repo.UpdatePage(page);
|
||||
await uow.CommitAsync(ct);
|
||||
await gl.PostIssuedChequeAsync(page, request.Action, ct);
|
||||
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.FinanceReport;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies the General Ledger service's reports (TrialBalance, GeneralLedger, BalanceSheet, ProfitAndLoss, CashFlow,
|
||||
/// BudgetVsActual, TaxSummary). Query parameters are forwarded as-is; Json is returned as the GL data payload and
|
||||
/// Pdf/Csv are returned as downloadable files.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/reports/finance")]
|
||||
public class FinanceReportController(IGeneralLedgerClient gl) : ControllerBase
|
||||
{
|
||||
/// <summary>Active GL accounts (code + name) for the report filters' account dropdown.</summary>
|
||||
[HttpGet("accounts")]
|
||||
[Authorize("finance-reports.view")]
|
||||
public async Task<IActionResult> Accounts(CancellationToken ct)
|
||||
{
|
||||
GeneralLedgerResult result;
|
||||
try { result = await gl.SendAsync(HttpMethod.Get, "v1/accounts?isActive=true", null, ct); }
|
||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
||||
|
||||
if (!result.Success || GlJson.Data(result) is not { } data)
|
||||
return StatusCode(502, new { error = GlJson.Message(result) ?? "Could not load GL accounts." });
|
||||
|
||||
var items = GlJson.Get(data, "items");
|
||||
var accounts = items is { ValueKind: JsonValueKind.Array }
|
||||
? items.Value.EnumerateArray()
|
||||
.Select(a => new { accountCode = GlJson.GetString(a, "accountCode") ?? "", accountName = GlJson.GetString(a, "accountName") ?? "" })
|
||||
.OrderBy(a => a.accountCode, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: [];
|
||||
return Ok(accounts);
|
||||
}
|
||||
|
||||
[HttpGet("{reportType}")]
|
||||
[Authorize("finance-reports.view")]
|
||||
public async Task<IActionResult> Get(string reportType, [FromQuery] string outputFormat = "Json", CancellationToken ct = default)
|
||||
{
|
||||
var query = new StringBuilder($"v1/reports?reportType={Uri.EscapeDataString(reportType)}&outputFormat={Uri.EscapeDataString(outputFormat)}");
|
||||
foreach (var (key, values) in Request.Query)
|
||||
{
|
||||
if (key is "reportType" or "outputFormat") continue;
|
||||
foreach (var v in values)
|
||||
query.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(v ?? ""));
|
||||
}
|
||||
|
||||
GeneralLedgerResult result;
|
||||
try { result = await gl.SendAsync(HttpMethod.Get, query.ToString(), null, ct); }
|
||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
||||
|
||||
if (!result.Success)
|
||||
return StatusCode(result.StatusCode is >= 400 and < 500 ? result.StatusCode : 502,
|
||||
new { error = GlJson.Message(result) ?? "The General Ledger service rejected the request." });
|
||||
|
||||
var data = GlJson.Data(result);
|
||||
if (data is null) return StatusCode(502, new { error = "The General Ledger service returned no data." });
|
||||
|
||||
if (!outputFormat.Equals("Json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var base64 = GlJson.GetString(data.Value, "contentBase64");
|
||||
if (base64 is null) return StatusCode(502, new { error = "The General Ledger service returned no file." });
|
||||
return File(Convert.FromBase64String(base64),
|
||||
GlJson.GetString(data.Value, "contentType") ?? "application/octet-stream",
|
||||
GlJson.GetString(data.Value, "fileName"));
|
||||
}
|
||||
|
||||
return Content(data.Value.GetRawText(), "application/json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GeneralLedgerResult(int StatusCode, bool Success, JsonElement? Body);
|
||||
|
||||
public interface IGeneralLedgerClient
|
||||
{
|
||||
Task<bool> IsHealthyAsync(CancellationToken ct = default);
|
||||
Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service-to-service client for the General Ledger microservice. The GL service is not user-facing:
|
||||
/// every request carries the shared X-Api-Key (GeneralLedger:ApiKey, must equal the GL service's ApiKey:Value).
|
||||
/// <paramref name="path"/> is relative to GeneralLedger:BaseUrl and carries the API version (e.g. "v1/accounts", "v1/journal-entries"), so moving a call to v2 is a one-path change.
|
||||
/// </summary>
|
||||
public class GeneralLedgerClient(HttpClient http) : IGeneralLedgerClient
|
||||
{
|
||||
public async Task<bool> IsHealthyAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync("/health", ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
if (payload is not null) request.Content = JsonContent.Create(payload);
|
||||
|
||||
using var response = await http.SendAsync(request, ct);
|
||||
JsonElement? body = null;
|
||||
if (response.Content.Headers.ContentLength != 0)
|
||||
{
|
||||
try { body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct); }
|
||||
catch (JsonException) { }
|
||||
}
|
||||
return new GeneralLedgerResult((int)response.StatusCode, response.IsSuccessStatusCode, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GlSetupRequest(int? Year);
|
||||
|
||||
public record YearEndCloseRequest(int Year);
|
||||
|
||||
public record OpeningBalanceRequest(DateOnly Date, string? Narration, List<OpeningBalanceLine> Lines);
|
||||
|
||||
[ApiController]
|
||||
[Route("api/gl")]
|
||||
public class GlController(
|
||||
GlSetupService setup,
|
||||
GlPostingService posting,
|
||||
IGeneralLedgerClient gl,
|
||||
Distribution.Features.SysConfig.ISysConfigRepository configRepo,
|
||||
AppDBContext context,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Status()
|
||||
{
|
||||
var mapping = new Dictionary<string, string?>();
|
||||
foreach (var key in GlKeys.AllAccountKeys)
|
||||
mapping[key] = (await configRepo.GetByKeyAsync(key))?.Value;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
glReachable = await gl.IsHealthyAsync(),
|
||||
setupDone = (await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value == "true",
|
||||
openingBalancePosted = await context.GlPostingLogs.AnyAsync(l => l.Event == "OpeningBalance" && l.Status == GlPostingStatus.Posted),
|
||||
closedYears = await posting.GetClosedYearsAsync(),
|
||||
mapping,
|
||||
posted = await context.GlPostingLogs.CountAsync(l => l.Status == GlPostingStatus.Posted),
|
||||
failed = await context.GlPostingLogs.CountAsync(l => l.Status != GlPostingStatus.Posted)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Setup(GlSetupRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await setup.RunAsync(request.Year, currentUser.UserId, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("opening-balances")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> OpeningBalances(OpeningBalanceRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await posting.PostOpeningBalanceAsync(request.Date, request.Narration, request.Lines, ct);
|
||||
return row.Status == GlPostingStatus.Posted
|
||||
? Ok(new { status = row.Status, glJournalNo = row.GlJournalNo })
|
||||
: StatusCode(502, new { error = row.Error ?? "The General Ledger service did not accept the entry. It was saved and can be retried." });
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("year-end/preview")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> YearEndPreview([FromQuery] int year, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await posting.GetYearEndPreviewAsync(year, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("year-end/close")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> YearEndClose(YearEndCloseRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await posting.CloseYearAsync(request.Year, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("postings")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Postings([FromQuery] GlPostingStatus? status, [FromQuery] int take = 100)
|
||||
{
|
||||
var query = context.GlPostingLogs.AsQueryable();
|
||||
if (status is not null) query = query.Where(l => l.Status == status);
|
||||
var rows = await query.OrderByDescending(l => l.CreatedAt).Take(Math.Clamp(take, 1, 500))
|
||||
.Select(l => new { l.Id, l.Event, l.SourceId, l.Reference, l.Status, l.GlJournalNo, l.Error, l.Attempts, l.LastAttemptAt, l.CreatedAt })
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("postings/retry")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Retry(CancellationToken ct) =>
|
||||
Ok(new { posted = await posting.RetryFailedAsync(ct) });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
/// <summary>Small readers for the GL service's ApiResponse envelope ({statusCode, success, message, data}).</summary>
|
||||
public static class GlJson
|
||||
{
|
||||
public static JsonElement? Data(GeneralLedgerResult result) =>
|
||||
result.Body is { ValueKind: JsonValueKind.Object } body && Get(body, "data") is { } data ? data : null;
|
||||
|
||||
public static string? Message(GeneralLedgerResult result) =>
|
||||
result.Body is { ValueKind: JsonValueKind.Object } body ? Get(body, "message")?.GetString()
|
||||
?? Get(body, "Message")?.GetString() : null;
|
||||
|
||||
/// <summary>Case-insensitive property lookup (GL success bodies are camelCase, its error bodies PascalCase).</summary>
|
||||
public static JsonElement? Get(JsonElement element, string name)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var p in element.EnumerateObject())
|
||||
if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) return p.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? GetString(JsonElement element, string name)
|
||||
{
|
||||
var v = Get(element, name);
|
||||
return v is null || v.Value.ValueKind == JsonValueKind.Null ? null
|
||||
: v.Value.ValueKind == JsonValueKind.String ? v.Value.GetString() : v.Value.ToString();
|
||||
}
|
||||
|
||||
public static long? GetLong(JsonElement element, string name) =>
|
||||
long.TryParse(GetString(element, name), out var n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
/// <summary>SysConfig keys holding the GL account codes Distribution posts to (created by GlSetupService).</summary>
|
||||
public static class GlKeys
|
||||
{
|
||||
public const string SetupDone = "Gl.Setup.Done";
|
||||
|
||||
public const string CashEquivalents = "Gl.Account.CashEquivalents"; // parent of Cash in Hand + Bank Accounts (GL cash root)
|
||||
public const string CashInHand = "Gl.Account.CashInHand"; // parent of every Distribution Cash account
|
||||
public const string BankAccounts = "Gl.Account.BankAccounts"; // parent of every Distribution Bank account
|
||||
public const string ChequesInHand = "Gl.Account.ChequesInHand";
|
||||
public const string Receivable = "Gl.Account.Receivable";
|
||||
public const string Inventory = "Gl.Account.Inventory";
|
||||
public const string InputVat = "Gl.Account.InputVat";
|
||||
public const string InputNbt = "Gl.Account.InputNbt";
|
||||
public const string Payable = "Gl.Account.Payable";
|
||||
public const string ChequesIssued = "Gl.Account.ChequesIssued";
|
||||
public const string Equity = "Gl.Account.Equity"; // header; parent of the equity accounts below
|
||||
public const string ShareCapital = "Gl.Account.ShareCapital";
|
||||
public const string RetainedEarnings = "Gl.Account.RetainedEarnings";
|
||||
public const string Drawings = "Gl.Account.Drawings";
|
||||
public const string GeneralReserve = "Gl.Account.GeneralReserve";
|
||||
public const string RevaluationReserve = "Gl.Account.RevaluationReserve";
|
||||
public const string Sales = "Gl.Account.Sales";
|
||||
|
||||
public static readonly string[] AllAccountKeys =
|
||||
[
|
||||
CashEquivalents, CashInHand, BankAccounts, ChequesInHand, Receivable, Inventory,
|
||||
InputVat, InputNbt, Payable, ChequesIssued, Equity, ShareCapital, RetainedEarnings, Drawings,
|
||||
GeneralReserve, RevaluationReserve, Sales
|
||||
];
|
||||
|
||||
public static bool IsGlKey(string key) => key == SetupDone || AllAccountKeys.Contains(key);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GlLine(string AccountCode, decimal Debit, decimal Credit);
|
||||
|
||||
/// <summary>What closing a financial year would post. Closable is false with a Reason when it cannot be closed.</summary>
|
||||
public record YearEndPreview(int Year, string StartDate, string EndDate, decimal TotalIncome, decimal TotalExpenses,
|
||||
decimal NetProfit, bool Closable, string? Reason, bool AlreadyClosed);
|
||||
|
||||
/// <summary>
|
||||
/// One opening balance: Account is a key from OpeningAccounts (e.g. "Inventory", "ShareCapital") or "BankAccount"
|
||||
/// with BankAccountId. Amount is the account's normal balance (>= 0); the debit/credit side comes from the account.
|
||||
/// </summary>
|
||||
public record OpeningBalanceLine(string Account, Guid? BankAccountId, decimal Amount);
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors Distribution business events into the General Ledger as journal entries. Every public Post* method is
|
||||
/// called AFTER the business transaction has committed and never throws: a GL problem is recorded in GlPostingLogs
|
||||
/// (status Failed, payload kept) and can be retried later, so it can never block a sale, payment or cheque action.
|
||||
/// </summary>
|
||||
public class GlPostingService(
|
||||
IGeneralLedgerClient gl,
|
||||
ISysConfigRepository configRepo,
|
||||
GlSetupService setup,
|
||||
AppDBContext context,
|
||||
IUnitOfWork uow,
|
||||
ILogger<GlPostingService> logger)
|
||||
{
|
||||
private record Payload(string EntryDate, string JournalType, string SourceModule, string Reference, string Narration,
|
||||
string PreparedBy, List<PayloadLine> Lines);
|
||||
|
||||
private record PayloadLine(string AccountCode, decimal DebitAmount, decimal CreditAmount);
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
public Task PostInvoiceFinalizedAsync(Models.Invoice invoice, CancellationToken ct = default) =>
|
||||
PostAsync("InvoiceFinalized", invoice.Id, invoice.FinalizedAt ?? DateTime.Now, "INVOICE", $"Invoice {invoice.Id} finalized",
|
||||
async () => invoice.TotalAmount <= 0 ? null :
|
||||
[
|
||||
new GlLine(await Acct(GlKeys.Receivable), invoice.TotalAmount, 0),
|
||||
new GlLine(await Acct(GlKeys.Sales), 0, invoice.TotalAmount)
|
||||
], ct);
|
||||
|
||||
public Task PostCustomerPaymentAsync(Models.Payment payment, CancellationToken ct = default) =>
|
||||
PostAsync("CustomerPayment", payment.Id, payment.CollectedAt, "PAYMENT", $"Customer payment for invoice {payment.FK_InvoiceId}",
|
||||
async () =>
|
||||
{
|
||||
if (payment.Amount <= 0 || payment.Method == PaymentMethod.Credit) return null;
|
||||
var debit = payment.Method == PaymentMethod.Cash
|
||||
? await BankCode(payment.FK_BankAccountId)
|
||||
: await Acct(GlKeys.ChequesInHand);
|
||||
return [new GlLine(debit, payment.Amount, 0), new GlLine(await Acct(GlKeys.Receivable), 0, payment.Amount)];
|
||||
}, ct);
|
||||
|
||||
/// <summary>Deposit / Return / Cancel of a received cheque that came from a customer payment.</summary>
|
||||
public Task PostReceivedChequeAsync(Models.ReceivedCheque cheque, ReceivedChequeStatusAction action, CancellationToken ct = default)
|
||||
{
|
||||
if (action is not (ReceivedChequeStatusAction.Deposit or ReceivedChequeStatusAction.Return or ReceivedChequeStatusAction.Cancel))
|
||||
return Task.CompletedTask;
|
||||
|
||||
var date = action == ReceivedChequeStatusAction.Deposit ? cheque.DepositDate ?? DateTime.Now : DateTime.Now;
|
||||
return PostAsync($"ReceivedCheque{action}", cheque.Id, date, "CHEQUE", $"Received cheque {cheque.ChequeNo} {action}",
|
||||
async () =>
|
||||
{
|
||||
// Only cheques created by a customer payment were posted to Cheques-in-hand, so only they can move.
|
||||
if (!await context.Payments.AnyAsync(p => p.FK_ReceivedChequeId == cheque.Id, ct)) return null;
|
||||
var inHand = await Acct(GlKeys.ChequesInHand);
|
||||
return action switch
|
||||
{
|
||||
ReceivedChequeStatusAction.Deposit =>
|
||||
[new GlLine(await BankCode(cheque.FK_DepositBankAccountId), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)],
|
||||
ReceivedChequeStatusAction.Return =>
|
||||
[new GlLine(inHand, cheque.Amount, 0), new GlLine(await BankCode(cheque.FK_DepositBankAccountId), 0, cheque.Amount)],
|
||||
_ => [new GlLine(await Acct(GlKeys.Receivable), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)]
|
||||
};
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public Task PostGrnAsync(Models.GoodsReceipt grn, CancellationToken ct = default) =>
|
||||
PostAsync("Grn", grn.Id, grn.ReceivedAt, "GRN", $"Goods received {grn.Id}",
|
||||
async () =>
|
||||
{
|
||||
decimal net = 0, nbt = 0, vat = 0;
|
||||
foreach (var i in grn.Items.Where(i => i.IsActive))
|
||||
{
|
||||
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
|
||||
net += a.Gross - a.Discount;
|
||||
nbt += a.Nbt;
|
||||
vat += a.Vat;
|
||||
}
|
||||
var total = net + nbt + vat;
|
||||
if (total <= 0) return null;
|
||||
|
||||
var lines = new List<GlLine> { new(await Acct(GlKeys.Inventory), net, 0) };
|
||||
if (nbt > 0) lines.Add(new GlLine(await Acct(GlKeys.InputNbt), nbt, 0));
|
||||
if (vat > 0) lines.Add(new GlLine(await Acct(GlKeys.InputVat), vat, 0));
|
||||
lines.Add(new GlLine(await Acct(GlKeys.Payable), 0, total));
|
||||
return lines;
|
||||
}, ct);
|
||||
|
||||
public Task PostSupplierPaymentAsync(Models.SupplierPayment payment, CancellationToken ct = default) =>
|
||||
PostAsync("SupplierPayment", payment.Id, payment.PaidAt, "SUPPLIER_PAYMENT", $"Supplier payment {payment.Id}",
|
||||
async () =>
|
||||
{
|
||||
if (payment.Amount <= 0) return null;
|
||||
var credit = payment.Method == PaymentMethod.Cash
|
||||
? await BankCode(payment.FK_BankAccountId)
|
||||
: await Acct(GlKeys.ChequesIssued);
|
||||
return [new GlLine(await Acct(GlKeys.Payable), payment.Amount, 0), new GlLine(credit, 0, payment.Amount)];
|
||||
}, ct);
|
||||
|
||||
/// <summary>Clear / Bounce / Cancel of a cheque issued through a supplier payment.</summary>
|
||||
public Task PostIssuedChequeAsync(Models.ChequePage page, ChequePageStatusAction action, CancellationToken ct = default)
|
||||
{
|
||||
if (action == ChequePageStatusAction.Void) return Task.CompletedTask;
|
||||
|
||||
var date = action == ChequePageStatusAction.Clear ? page.ClearedDate ?? DateTime.Now : DateTime.Now;
|
||||
return PostAsync($"IssuedCheque{action}", page.Id, date, "CHEQUE", $"Issued cheque {page.ChequeNo} {action}",
|
||||
async () =>
|
||||
{
|
||||
var amount = page.Amount ?? 0;
|
||||
if (amount <= 0 || !await context.SupplierPayments.AnyAsync(p => p.FK_ChequePageId == page.Id, ct)) return null;
|
||||
var issued = await Acct(GlKeys.ChequesIssued);
|
||||
if (action == ChequePageStatusAction.Clear)
|
||||
{
|
||||
var bookBankId = await context.ChequeBooks.Where(b => b.Id == page.FK_ChequeBookId)
|
||||
.Select(b => (Guid?)b.FK_BankAccountId).FirstOrDefaultAsync(ct);
|
||||
return [new GlLine(issued, amount, 0), new GlLine(await BankCode(bookBankId), 0, amount)];
|
||||
}
|
||||
// Bounce / Cancel: the cheque no longer settles the payable, so put it back.
|
||||
return [new GlLine(issued, amount, 0), new GlLine(await Acct(GlKeys.Payable), 0, amount)];
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts the opening balances (asset side debited, liability/equity side credited). Zero amounts are skipped, so the
|
||||
/// form can list every account with a default of 0. Reports the outcome so the caller can show a failure.
|
||||
/// </summary>
|
||||
public async Task<GlPostingLog> PostOpeningBalanceAsync(DateOnly date, string? narration, List<OpeningBalanceLine> input, CancellationToken ct = default)
|
||||
{
|
||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
|
||||
throw new InvalidOperationException("Run the General Ledger setup first.");
|
||||
|
||||
var lines = new List<GlLine>();
|
||||
foreach (var l in input ?? [])
|
||||
{
|
||||
if (l.Amount < 0) throw new InvalidOperationException($"'{l.Account}': the amount cannot be negative.");
|
||||
if (l.Amount == 0) continue;
|
||||
|
||||
if (l.Account == "BankAccount")
|
||||
{
|
||||
lines.Add(new GlLine(await BankCode(l.BankAccountId), l.Amount, 0));
|
||||
continue;
|
||||
}
|
||||
if (!OpeningAccounts.TryGetValue(l.Account, out var def))
|
||||
throw new InvalidOperationException($"Unknown account '{l.Account}'.");
|
||||
var code = await Acct(def.Key);
|
||||
lines.Add(def.DebitNormal ? new GlLine(code, l.Amount, 0) : new GlLine(code, 0, l.Amount));
|
||||
}
|
||||
|
||||
if (lines.Count == 0)
|
||||
throw new InvalidOperationException("Enter at least one opening balance greater than zero.");
|
||||
|
||||
var debit = lines.Sum(x => x.Debit);
|
||||
var credit = lines.Sum(x => x.Credit);
|
||||
if (debit != credit)
|
||||
throw new InvalidOperationException(
|
||||
$"The opening balances do not balance: assets (debits) total {debit:N2} but liabilities + equity (credits) total {credit:N2}. " +
|
||||
"Adjust Share Capital / Retained Earnings so they match.");
|
||||
|
||||
var sourceId = Guid.NewGuid();
|
||||
await PostAsync("OpeningBalance", sourceId, date.ToDateTime(TimeOnly.MinValue), "OPENING_BALANCE",
|
||||
string.IsNullOrWhiteSpace(narration) ? "Opening balances" : narration.Trim(), () => Task.FromResult<List<GlLine>?>(lines), ct);
|
||||
|
||||
return await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == "OpeningBalance" && l.SourceId == sourceId, ct)
|
||||
?? throw new InvalidOperationException("The opening balances could not be prepared. Check the server log.");
|
||||
}
|
||||
|
||||
/// <summary>Accounts that can carry an opening balance (besides individual cash/bank accounts) and their normal side.</summary>
|
||||
public static readonly Dictionary<string, (string Key, bool DebitNormal)> OpeningAccounts = new()
|
||||
{
|
||||
["ChequesInHand"] = (GlKeys.ChequesInHand, true),
|
||||
["Receivable"] = (GlKeys.Receivable, true),
|
||||
["Inventory"] = (GlKeys.Inventory, true),
|
||||
["InputVat"] = (GlKeys.InputVat, true),
|
||||
["InputNbt"] = (GlKeys.InputNbt, true),
|
||||
["Drawings"] = (GlKeys.Drawings, true),
|
||||
["Payable"] = (GlKeys.Payable, false),
|
||||
["ChequesIssued"] = (GlKeys.ChequesIssued, false),
|
||||
["ShareCapital"] = (GlKeys.ShareCapital, false),
|
||||
["RetainedEarnings"] = (GlKeys.RetainedEarnings, false),
|
||||
["GeneralReserve"] = (GlKeys.GeneralReserve, false),
|
||||
["RevaluationReserve"] = (GlKeys.RevaluationReserve, false),
|
||||
};
|
||||
|
||||
// ── Year-end close ───────────────────────────────────────────────────────
|
||||
|
||||
private const string YearEndEvent = "YearEndClose";
|
||||
|
||||
private static Guid YearEndSourceId(int year) =>
|
||||
new(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes($"{YearEndEvent}:{year}")));
|
||||
|
||||
/// <summary>Years that have a posted closing journal.</summary>
|
||||
public async Task<List<int>> GetClosedYearsAsync(CancellationToken ct = default) =>
|
||||
(await context.GlPostingLogs
|
||||
.Where(l => l.Event == YearEndEvent && l.Status == GlPostingStatus.Posted)
|
||||
.Select(l => l.Reference)
|
||||
.ToListAsync(ct))
|
||||
.Select(r => int.TryParse(r.Split(':').Last(), out var y) ? y : 0)
|
||||
.Where(y => y > 0).OrderBy(y => y).ToList();
|
||||
|
||||
private record CloseBuild(List<GlLine> Lines, decimal Income, decimal Expenses);
|
||||
|
||||
/// <summary>Reads the year's own P&L from GL (which excludes earlier closing journals) and builds the closing lines.</summary>
|
||||
private async Task<CloseBuild> BuildYearEndCloseAsync(int year, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Get,
|
||||
$"v1/reports?reportType=ProfitAndLoss&outputFormat=Json&periodStart={year}-01-01&periodEnd={year}-12-31", null, ct);
|
||||
if (!result.Success || GlJson.Data(result) is not { } pl)
|
||||
throw new InvalidOperationException($"Could not read the {year} profit and loss from the General Ledger: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
||||
|
||||
// Net CREDIT balance per account: income sections carry their amount as is, expense sections are reported
|
||||
// as positive debits (so flip), unclassified rows are already credit - debit.
|
||||
var netCredit = new Dictionary<string, decimal>();
|
||||
decimal income = 0, expenses = 0;
|
||||
void Collect(string section, int sign)
|
||||
{
|
||||
if (GlJson.Get(pl, section) is not { } s || GlJson.Get(s, "lines") is not { ValueKind: JsonValueKind.Array } sectionLines) return;
|
||||
foreach (var l in sectionLines.EnumerateArray())
|
||||
{
|
||||
var code = GlJson.GetString(l, "accountCode");
|
||||
if (code is null || !decimal.TryParse(GlJson.GetString(l, "amount"), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue;
|
||||
var credit = sign * amount;
|
||||
netCredit[code] = netCredit.GetValueOrDefault(code) + credit;
|
||||
if (credit > 0) income += credit; else expenses += -credit;
|
||||
}
|
||||
}
|
||||
Collect("sales", 1); Collect("otherIncome", 1);
|
||||
Collect("costOfSales", -1); Collect("distributionExpenses", -1); Collect("administrationExpenses", -1);
|
||||
Collect("otherExpenses", -1); Collect("financialExpenses", -1);
|
||||
Collect("unclassified", 1);
|
||||
|
||||
// Closing: an account with a net credit balance is debited, one with a net debit balance is credited.
|
||||
var lines = netCredit.Where(kv => kv.Value != 0)
|
||||
.Select(kv => kv.Value > 0 ? new GlLine(kv.Key, kv.Value, 0) : new GlLine(kv.Key, 0, -kv.Value)).ToList();
|
||||
var debit = lines.Sum(x => x.Debit);
|
||||
var credit2 = lines.Sum(x => x.Credit);
|
||||
if (debit != credit2)
|
||||
{
|
||||
var retained = await Acct(GlKeys.RetainedEarnings);
|
||||
lines.Add(debit > credit2 ? new GlLine(retained, 0, debit - credit2) : new GlLine(retained, credit2 - debit, 0));
|
||||
}
|
||||
return new CloseBuild(lines, income, expenses);
|
||||
}
|
||||
|
||||
public async Task<YearEndPreview> GetYearEndPreviewAsync(int year, CancellationToken ct = default)
|
||||
{
|
||||
var start = $"{year}-01-01";
|
||||
var end = $"{year}-12-31";
|
||||
|
||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
|
||||
return new(year, start, end, 0, 0, 0, false, "Run the General Ledger setup first.", false);
|
||||
|
||||
var sourceId = YearEndSourceId(year);
|
||||
var existing = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct);
|
||||
if (existing?.Status == GlPostingStatus.Posted)
|
||||
return new(year, start, end, 0, 0, 0, false, $"{year} has already been closed.", true);
|
||||
|
||||
var build = await BuildYearEndCloseAsync(year, ct);
|
||||
var net = build.Income - build.Expenses;
|
||||
string? reason = null;
|
||||
if (new DateOnly(year, 12, 31) > DateOnly.FromDateTime(DateTime.Now))
|
||||
reason = $"{year} has not ended yet. A year can only be closed after 31 December.";
|
||||
else if (await context.GlPostingLogs.AnyAsync(l => l.Status != GlPostingStatus.Posted && !(l.Event == YearEndEvent && l.SourceId == sourceId), ct))
|
||||
reason = "There are failed or pending General Ledger postings. Retry them first so the year's figures are complete.";
|
||||
else if (build.Lines.Count == 0)
|
||||
reason = $"There is no income or expense activity in {year} to close.";
|
||||
|
||||
return new(year, start, end, build.Income, build.Expenses, net, reason is null, reason, false);
|
||||
}
|
||||
|
||||
/// <summary>Posts the closing journal for the year (income/expense into Retained Earnings), then locks the year's periods.</summary>
|
||||
public async Task<object> CloseYearAsync(int year, CancellationToken ct = default)
|
||||
{
|
||||
var preview = await GetYearEndPreviewAsync(year, ct);
|
||||
if (!preview.Closable) throw new InvalidOperationException(preview.Reason ?? "This year cannot be closed.");
|
||||
|
||||
var build = await BuildYearEndCloseAsync(year, ct);
|
||||
var sourceId = YearEndSourceId(year);
|
||||
await PostAsync(YearEndEvent, sourceId, new DateTime(year, 12, 31), "YEAR_END_CLOSE", $"Year-end closing entry for {year}",
|
||||
() => Task.FromResult<List<GlLine>?>(build.Lines), ct, journalType: "CLOSING", reference: $"{YearEndEvent}:{year}");
|
||||
|
||||
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct)
|
||||
?? throw new InvalidOperationException("The closing entry could not be prepared. Check the server log.");
|
||||
if (row.Status != GlPostingStatus.Posted)
|
||||
throw new InvalidOperationException((row.Error ?? "The General Ledger did not accept the closing entry.") + " Fix the cause and press Close again.");
|
||||
|
||||
var locked = await LockYearPeriodsAsync(year, ct);
|
||||
return new { year, glJournalNo = row.GlJournalNo, netProfit = preview.NetProfit, periodsLocked = locked };
|
||||
}
|
||||
|
||||
private async Task<int> LockYearPeriodsAsync(int year, CancellationToken ct)
|
||||
{
|
||||
var ids = new HashSet<long>();
|
||||
for (var month = 1; month <= 12; month++)
|
||||
{
|
||||
var p = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={year}-{month:00}-15", null, ct);
|
||||
if (p.Success && GlJson.Data(p) is { } pd && GlJson.GetLong(pd, "periodId") is { } id) ids.Add(id);
|
||||
}
|
||||
|
||||
var locked = 0;
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var r = await gl.SendAsync(HttpMethod.Put, $"v1/periods/{id}/lock", new { action = "Close", reason = $"Year-end close {year}" }, ct);
|
||||
if (r.Success) locked++;
|
||||
else logger.LogWarning("Could not lock GL period {PeriodId} after closing {Year}: {Message}", id, year, GlJson.Message(r));
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
|
||||
// ── Retry ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Re-sends every Failed/Pending posting. Returns how many are now Posted.</summary>
|
||||
public async Task<int> RetryFailedAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await context.GlPostingLogs
|
||||
.Where(l => l.Status != GlPostingStatus.Posted)
|
||||
.OrderBy(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var posted = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
await SendAsync(row, ct);
|
||||
if (row.Status == GlPostingStatus.Posted) posted++;
|
||||
}
|
||||
return posted;
|
||||
}
|
||||
|
||||
// ── Core ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task PostAsync(string evt, Guid sourceId, DateTime date, string sourceModule, string narration,
|
||||
Func<Task<List<GlLine>?>> buildLines, CancellationToken ct, string journalType = "GENERAL", string? reference = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") return; // GL not set up yet
|
||||
|
||||
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == evt && l.SourceId == sourceId, ct);
|
||||
if (row is not null && row.Status == GlPostingStatus.Posted) return;
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
var lines = await buildLines();
|
||||
if (lines is null || lines.Count == 0) return;
|
||||
|
||||
reference ??= $"{evt}:{sourceId}";
|
||||
var payload = new Payload(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), journalType, sourceModule, reference,
|
||||
narration, "Distribution", lines.Select(l => new PayloadLine(l.AccountCode, l.Debit, l.Credit)).ToList());
|
||||
|
||||
row = new GlPostingLog
|
||||
{
|
||||
Event = evt,
|
||||
SourceId = sourceId,
|
||||
Reference = reference,
|
||||
PayloadJson = JsonSerializer.Serialize(payload, JsonOptions)
|
||||
};
|
||||
await uow.BeginAsync(ct);
|
||||
try { await context.GlPostingLogs.AddAsync(row, ct); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
}
|
||||
|
||||
await SendAsync(row, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "GL posting {Event}:{SourceId} failed before it could be sent", evt, sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendAsync(GlPostingLog row, CancellationToken ct)
|
||||
{
|
||||
string? journalNo = null;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<JsonElement>(row.PayloadJson);
|
||||
var entryDate = payload.GetProperty("entryDate").GetString()!;
|
||||
|
||||
var period = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={entryDate}", null, ct);
|
||||
var periodId = period.Success && GlJson.Data(period) is { } pd ? GlJson.GetLong(pd, "periodId") : null;
|
||||
if (periodId is null)
|
||||
{
|
||||
error = $"No accounting period in GL covers {entryDate}. Run GL setup for that year.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var body = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(row.PayloadJson, JsonOptions)!;
|
||||
body["periodId"] = JsonSerializer.SerializeToElement(periodId.Value);
|
||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/journal-entries", body, ct);
|
||||
if (result.Success)
|
||||
journalNo = GlJson.Data(result) is { } d ? GlJson.GetString(d, "journalNo") ?? "" : "";
|
||||
else
|
||||
error = GlJson.Message(result) ?? $"GL returned HTTP {result.StatusCode}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
logger.LogWarning(ex, "GL posting {Reference} failed", row.Reference);
|
||||
}
|
||||
|
||||
row.Attempts++;
|
||||
row.LastAttemptAt = DateTime.Now;
|
||||
row.Status = error is null ? GlPostingStatus.Posted : GlPostingStatus.Failed;
|
||||
row.GlJournalNo = journalNo;
|
||||
row.Error = error is null ? null : error.Length > 1000 ? error[..1000] : error;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { context.GlPostingLogs.Update(row); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
}
|
||||
|
||||
// ── Account lookups ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> Acct(string key) =>
|
||||
(await configRepo.GetByKeyAsync(key))?.Value is { Length: > 0 } code
|
||||
? code
|
||||
: throw new InvalidOperationException($"GL account mapping '{key}' is missing. Run GL setup.");
|
||||
|
||||
private async Task<string> BankCode(Guid? bankAccountId)
|
||||
{
|
||||
if (bankAccountId is null) throw new InvalidOperationException("No cash/bank account on this transaction.");
|
||||
var account = await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == bankAccountId.Value)
|
||||
?? throw new InvalidOperationException("Cash/bank account not found.");
|
||||
return await setup.EnsureBankAccountAsync(account)
|
||||
?? throw new InvalidOperationException("Could not map the cash/bank account to a GL account. Run GL setup.");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GlSetupResult(bool Done, List<string> Actions);
|
||||
|
||||
/// <summary>
|
||||
/// One-time (re-runnable) bootstrap of the General Ledger service for Distribution: creates the chart of accounts
|
||||
/// Distribution posts to, a fiscal year with its periods, the GL cash-root config, and a GL account for every
|
||||
/// Distribution cash/bank account. The resulting GL account codes are stored in SysConfig (see GlKeys).
|
||||
/// </summary>
|
||||
public class GlSetupService(
|
||||
IGeneralLedgerClient gl,
|
||||
ISysConfigRepository configRepo,
|
||||
AppDBContext context,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const short Asset = 1, Liability = 2, Equity = 3, Income = 4;
|
||||
|
||||
// key, GL account name, account type, parent key, postable, tag name, tag value.
|
||||
// Operating = working-capital account for the Cash Flow report (cashFlowCategory OPERATING).
|
||||
private record AccountDef(string Key, string Name, short TypeId, string? ParentKey, bool Postable, string? TagField, string? TagValue, bool Operating = false);
|
||||
|
||||
private static readonly AccountDef[] Chart =
|
||||
[
|
||||
new(GlKeys.CashEquivalents, "Cash and Cash Equivalents", Asset, null, false, "balanceSheetClassification", "CURRENT_ASSET"),
|
||||
new(GlKeys.CashInHand, "Cash in Hand", Asset, GlKeys.CashEquivalents, false, null, null),
|
||||
new(GlKeys.BankAccounts, "Bank Accounts", Asset, GlKeys.CashEquivalents, false, null, null),
|
||||
new(GlKeys.ChequesInHand, "Cheques in Hand", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.Receivable, "Trade Receivables", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.Inventory, "Inventory", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.InputVat, "Input VAT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.InputNbt, "Input NBT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.Payable, "Trade Payables", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
||||
new(GlKeys.ChequesIssued, "Cheques Issued - Uncleared", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
||||
new(GlKeys.Equity, "Owner's Equity", Equity, null, false, null, null),
|
||||
new(GlKeys.ShareCapital, "Share Capital / Owner's Capital", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.RetainedEarnings, "Retained Earnings", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.Drawings, "Drawings and Dividends", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.GeneralReserve, "General Reserve", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.RevaluationReserve, "Revaluation Reserve", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.Sales, "Sales Revenue", Income, null, true, "incomeStatementSection", "SALES"),
|
||||
];
|
||||
|
||||
public async Task<GlSetupResult> RunAsync(int? year, Guid? userId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await gl.IsHealthyAsync(ct))
|
||||
throw new InvalidOperationException("The General Ledger service is not reachable. Check GeneralLedger:BaseUrl and that the service is running.");
|
||||
|
||||
var actions = new List<string>();
|
||||
var codes = new Dictionary<string, string>();
|
||||
|
||||
foreach (var key in GlKeys.AllAccountKeys)
|
||||
{
|
||||
var existing = (await configRepo.GetByKeyAsync(key))?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(existing)) codes[key] = existing;
|
||||
}
|
||||
|
||||
// 1. Chart of accounts (skips accounts whose code is already stored and still exists in GL).
|
||||
foreach (var def in Chart)
|
||||
{
|
||||
if (codes.TryGetValue(def.Key, out var code) && await AccountIdAsync(code, ct) is not null)
|
||||
continue;
|
||||
|
||||
long? parentId = null;
|
||||
if (def.ParentKey is not null)
|
||||
parentId = await AccountIdAsync(codes[def.ParentKey], ct);
|
||||
|
||||
var created = await CreateAccountAsync(def.Name, def.TypeId, parentId, def.Postable, ct);
|
||||
codes[def.Key] = created.Code;
|
||||
actions.Add($"Created GL account {created.Code} - {def.Name}");
|
||||
}
|
||||
|
||||
// Tags are applied on every run (idempotent), so accounts created by an earlier run are corrected too.
|
||||
foreach (var def in Chart)
|
||||
{
|
||||
var tags = new Dictionary<string, object?>();
|
||||
if (def.TagField is not null) tags[def.TagField] = def.TagValue;
|
||||
if (def.Operating) tags["cashFlowCategory"] = "OPERATING";
|
||||
if (!def.Postable) tags["isPostable"] = false; // header accounts (re-applied so older runs are corrected)
|
||||
if (tags.Count > 0) await UpdateAccountAsync(codes[def.Key], tags, ct);
|
||||
}
|
||||
|
||||
// 2. Fiscal year + periods for the requested year (default: current year).
|
||||
var fy = year ?? DateTime.Now.Year;
|
||||
var probe = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={fy}-06-30", null, ct);
|
||||
if (!probe.Success)
|
||||
{
|
||||
var fyResult = await gl.SendAsync(HttpMethod.Post, "v1/fiscal-years", new
|
||||
{
|
||||
yearName = $"FY{fy}",
|
||||
startDate = $"{fy}-01-01",
|
||||
endDate = $"{fy}-12-31",
|
||||
autoGeneratePeriods = true,
|
||||
periodCount = 12
|
||||
}, ct);
|
||||
if (!fyResult.Success)
|
||||
throw new InvalidOperationException($"Could not create fiscal year {fy} in GL: {GlJson.Message(fyResult) ?? fyResult.StatusCode.ToString()}");
|
||||
actions.Add($"Created fiscal year FY{fy} with 12 periods");
|
||||
}
|
||||
|
||||
// 3. GL cash root (GL locks this key after one edit, so only set it when empty).
|
||||
var cashRootId = await AccountIdAsync(codes[GlKeys.CashEquivalents], ct);
|
||||
var cfgResult = await gl.SendAsync(HttpMethod.Get, "v1/system-config", null, ct);
|
||||
var currentRoot = ReadGlConfigValue(cfgResult, "CASH_ACCOUNT_ROOT_ID");
|
||||
if (string.IsNullOrWhiteSpace(currentRoot) && cashRootId is not null)
|
||||
{
|
||||
var set = await gl.SendAsync(HttpMethod.Put, "v1/system-config/CASH_ACCOUNT_ROOT_ID",
|
||||
new { configValue = cashRootId.Value.ToString() }, ct);
|
||||
if (!set.Success)
|
||||
throw new InvalidOperationException($"Could not set CASH_ACCOUNT_ROOT_ID in GL: {GlJson.Message(set) ?? set.StatusCode.ToString()}");
|
||||
actions.Add("Set GL CASH_ACCOUNT_ROOT_ID");
|
||||
}
|
||||
|
||||
// 4. Persist the mapping, then mirror every existing Distribution cash/bank account.
|
||||
await SaveConfigAsync(codes, userId, ct);
|
||||
|
||||
foreach (var account in await context.BankAccounts.Where(a => a.GlAccountCode == null).ToListAsync(ct))
|
||||
{
|
||||
var code = await EnsureBankAccountAsync(account, ct);
|
||||
if (code is not null) actions.Add($"Mirrored {account.AccountName} as GL account {code}");
|
||||
}
|
||||
|
||||
await SaveConfigAsync(new Dictionary<string, string> { [GlKeys.SetupDone] = "true" }, userId, ct);
|
||||
return new GlSetupResult(true, actions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GL account code for a Distribution cash/bank account, creating the GL child account on first use.
|
||||
/// Saves the code on the BankAccount. Returns null if GL setup has not been run.
|
||||
/// </summary>
|
||||
public async Task<string?> EnsureBankAccountAsync(Models.BankAccount account, CancellationToken ct = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account.GlAccountCode)) return account.GlAccountCode;
|
||||
|
||||
var parentKey = account.AccountType == BankAccountType.Bank ? GlKeys.BankAccounts : GlKeys.CashInHand;
|
||||
var parentCode = (await configRepo.GetByKeyAsync(parentKey))?.Value;
|
||||
if (string.IsNullOrWhiteSpace(parentCode)) return null;
|
||||
|
||||
var parentId = await AccountIdAsync(parentCode, ct);
|
||||
if (parentId is null) return null;
|
||||
|
||||
var name = account.AccountType == BankAccountType.Bank && !string.IsNullOrWhiteSpace(account.BankName)
|
||||
? $"{account.BankName} - {account.AccountName}"
|
||||
: account.AccountName;
|
||||
|
||||
var created = await CreateAccountAsync(name, Asset, parentId, true, ct);
|
||||
|
||||
account.GlAccountCode = created.Code;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
context.BankAccounts.Update(account);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return created.Code;
|
||||
}
|
||||
|
||||
private async Task SaveConfigAsync(Dictionary<string, string> values, Guid? userId, CancellationToken ct)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var (key, value) in values)
|
||||
await configRepo.UpsertAsync(new Models.SysConfig { Key = key, Value = value, UpdatedAt = DateTime.Now, UpdatedBy = userId });
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
}
|
||||
|
||||
private async Task<long?> AccountIdAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Get, $"v1/accounts/{Uri.EscapeDataString(code)}", null, ct);
|
||||
if (!result.Success) return null;
|
||||
var data = GlJson.Data(result);
|
||||
if (data is null) return null;
|
||||
// GET /accounts/{code} wraps the account: { account: {...}, ... }
|
||||
var account = GlJson.Get(data.Value, "account") ?? data.Value;
|
||||
return GlJson.GetLong(account, "accountId");
|
||||
}
|
||||
|
||||
private async Task<(long Id, string Code)> CreateAccountAsync(string name, short typeId, long? parentId, bool postable, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/accounts", new
|
||||
{
|
||||
accountName = name,
|
||||
parentAccountId = parentId,
|
||||
accountTypeId = typeId,
|
||||
isControlAccount = false,
|
||||
isPostable = postable
|
||||
}, ct);
|
||||
if (!result.Success)
|
||||
throw new InvalidOperationException($"Could not create GL account '{name}': {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
||||
|
||||
var data = GlJson.Data(result) ?? throw new InvalidOperationException($"GL returned no data when creating account '{name}'.");
|
||||
var id = GlJson.GetLong(data, "accountId");
|
||||
var code = GlJson.GetString(data, "accountCode");
|
||||
if (id is null || code is null)
|
||||
throw new InvalidOperationException($"GL returned an unexpected body when creating account '{name}'.");
|
||||
return (id.Value, code);
|
||||
}
|
||||
|
||||
private async Task UpdateAccountAsync(string code, Dictionary<string, object?> fields, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Put, $"v1/accounts/{Uri.EscapeDataString(code)}", fields, ct);
|
||||
if (!result.Success)
|
||||
throw new InvalidOperationException($"Could not update GL account {code}: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
||||
}
|
||||
|
||||
private static string? ReadGlConfigValue(GeneralLedgerResult result, string key)
|
||||
{
|
||||
if (!result.Success || GlJson.Data(result) is not { } data) return null;
|
||||
var items = data.ValueKind == JsonValueKind.Array ? data : GlJson.Get(data, "items");
|
||||
if (items is not { ValueKind: JsonValueKind.Array }) return null;
|
||||
foreach (var item in items.Value.EnumerateArray())
|
||||
if (GlJson.GetString(item, "configKey") == key) return GlJson.GetString(item, "configValue");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.SysConfig;
|
||||
@@ -13,7 +14,8 @@ public class GrnService(
|
||||
IUnitOfWork uow,
|
||||
Distribution.Features.Item.IItemRepository itemRepo,
|
||||
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||
{
|
||||
@@ -482,6 +484,7 @@ public class GrnService(
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||
await gl.PostGrnAsync(saved!, ct);
|
||||
return Map(saved!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -8,7 +9,8 @@ namespace Distribution.Features.Invoice;
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||
new(i.Id, i.FK_ShopPOId,
|
||||
@@ -96,6 +98,8 @@ public class InvoiceService(
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await gl.PostInvoiceFinalizedAsync(invoice, ct);
|
||||
|
||||
return ToResponse(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.StoreCredit;
|
||||
@@ -15,7 +16,8 @@ public class PaymentService(
|
||||
IReceivedChequeRepository receivedChequeRepo,
|
||||
IStoreCreditMovementRepository creditRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
||||
@@ -150,14 +152,13 @@ public class PaymentService(
|
||||
|
||||
invoiceRepo.Update(invoice);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Cash/Bank (BankAccount) OR Cheques-in-hand (for Cheque payments)
|
||||
// Credit Accounts Receivable (invoice.Store)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// Mirrors to the General Ledger after commit: Dr Cash/Bank (or Cheques-in-hand) / Cr Receivable. Never throws.
|
||||
await gl.PostCustomerPaymentAsync(payment, ct);
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -8,7 +9,8 @@ namespace Distribution.Features.ReceivedCheque;
|
||||
public class ReceivedChequeService(
|
||||
IReceivedChequeRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow)
|
||||
IUnitOfWork uow,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
||||
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
||||
@@ -110,6 +112,7 @@ public class ReceivedChequeService(
|
||||
|
||||
repo.Update(cheque);
|
||||
await uow.CommitAsync(ct);
|
||||
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
|
||||
return ToResponse(cheque);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Supplier;
|
||||
using Distribution.Infra.Authorization;
|
||||
@@ -17,7 +18,8 @@ public class SupplierPaymentService(
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IChequeBookRepository chequeBookRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
||||
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
||||
@@ -161,14 +163,13 @@ public class SupplierPaymentService(
|
||||
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Accounts Payable (supplier)
|
||||
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// Mirrors to the General Ledger after commit: Dr Payable / Cr Cash/Bank (or Cheques-issued). Never throws.
|
||||
await gl.PostSupplierPaymentAsync(payment, ct);
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ namespace Distribution.Migrations
|
||||
table: "DeliveryTasks",
|
||||
newName: "FK_ShopPOId");
|
||||
|
||||
// MariaDB drops the ascii collation on CHANGE COLUMN; restore it so the FK to ShopPOs.Id can be created.
|
||||
migrationBuilder.Sql("ALTER TABLE `DeliveryTasks` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE `SchemeRedemptions` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE `SaleReturns` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE `Payments` MODIFY `FK_InvoiceId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_DeliveryTasks_FK_OrderId",
|
||||
table: "DeliveryTasks",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class GlIntegration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlAccountCode",
|
||||
table: "BankAccounts",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GlPostingLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Event = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
SourceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Reference = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
PayloadJson = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
GlJournalNo = table.Column<string>(type: "longtext", nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Error = table.Column<string>(type: "longtext", nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Attempts = table.Column<int>(type: "int", nullable: false),
|
||||
LastAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GlPostingLogs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "BankAccounts",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
||||
column: "GlAccountCode",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Permissions",
|
||||
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("20000000-0000-0000-0000-000000000051"), "finance-reports.view", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "View financial reports (trial balance, P&L, balance sheet, cash flow...)", true },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000052"), "gl.manage", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Set up the General Ledger link and retry failed postings", true }
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("52dedbb8-6251-4131-82de-41c2064a663b"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("47450af3-cf95-4725-a208-eeca124022b7"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("44deed45-fc6f-420c-a026-f4483efab782"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("16ad892c-b785-4872-a426-1015f32c90b9"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("498230b5-7d98-4826-afaf-944123658f69"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("86229744-f68d-4e7e-9b07-2aad58584331"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("48772797-f181-423d-bfb3-f6226a9abe11"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("8f63c426-38bb-456d-adff-75e477857607"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("12e98296-456e-40a3-b11c-61d3393b335e"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5e701ad5-f462-4642-86d1-303148323a32"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("a2515783-f099-4332-a07c-be81f2e85160"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("93c036e4-d811-4915-b73c-1cb358f17065"));
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "RolePermissions",
|
||||
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("20000000-0000-0000-0000-000000000051"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("9c27d325-1915-41c6-9b62-d85a12f102e1"), true },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1f48dd7b-2f14-451d-b80d-71579d5625e6"), true }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GlPostingLogs_Event_SourceId",
|
||||
table: "GlPostingLogs",
|
||||
columns: new[] { "Event", "SourceId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "GlPostingLogs");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000051"), new Guid("10000000-0000-0000-0000-000000000007") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Permissions",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000051"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Permissions",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000052"));
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlAccountCode",
|
||||
table: "BankAccounts");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("be712eda-98a5-4a30-820a-1737e84d1e4b"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("47ea9420-d6a0-4376-a2b8-211639d95b27"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("369eed1f-df4c-4131-a3c6-ebbe63d2dc91"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("14b6944f-2d4a-4378-a974-c6fc0f9dfb81"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("14a01e0b-7be5-4c65-ae6a-899f9aa7ad28"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5d486ccd-3866-4e7d-8ef1-fb177fb637c1"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("d830e59b-f451-4bde-9a77-78daea5f9a61"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("096d3837-a649-4184-8987-2ea0dfa2d4a1"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5e43bbde-689b-4d07-baf9-0764078b5586"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("f317d30c-1644-44f0-b0aa-6bcf1fa66f44"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("31ffac6f-2aa8-48ee-bc87-213f35933de4"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("fb525bc4-9f16-455a-bfd5-ca6f3e7ad141"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("2388918f-6392-4384-b269-0a1469f5bf4c"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("7159012e-5f25-4881-b5e8-5300e382332e"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("cc4b2e82-ad42-4440-92ba-9b2100bb5f84"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("6b15c133-ea0e-473d-b58a-7f140e9f03bf"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("c2dd52a9-380f-479f-8e5e-6b56bfc4738f"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("bf5f6c7a-2fde-4a01-83f4-aacf91333c4c"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("162a0346-c4c0-4c4e-96e9-aa8154d25b39"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5cc76807-25d1-45e5-9da3-cfd6d6f3ad7c"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("51a2653e-4b1a-4f6f-80c2-f533b26109ea"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("e2a382e2-d712-4a97-a7ec-cd6232d77a78"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("b60260b5-70b0-4f9e-9ab9-ba4abbbdfb02"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("a73fe794-383c-4157-89cf-227c4e487a6d"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("a9743e96-15b2-4c1d-b410-698cd6db0b7b"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("a94d92ab-66d8-4dd9-8ba3-c242eb450e68"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("9cb1a028-a98e-492a-aafc-70456fe46c96"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5bc99a62-f5d7-48fe-b25c-518e4774614c"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("eceee678-5cee-4ee9-b16d-8d8d21fbb5ea"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("3a049ed7-75b7-4b1d-b3bb-402ca77c350f"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("aeaab85c-5b02-4bc2-b0e3-318c0f5b07d3"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("b79800e0-baa6-4add-9374-7d1e2ed650e8"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("0da6bf0b-26ff-46b9-bb8c-f9c641b37a89"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("ff84aeeb-93c0-465d-9a4a-8b686ba7bc75"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("921f8073-7a66-49bb-ae23-cf8fcde12c9c"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("bff05501-4988-4c8a-b61d-e32389e795a8"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("2c4f7432-e697-4d65-97df-069f8913a662"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("18d27746-3178-4c57-afd5-b75f0061637f"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("a5d1978b-c497-4314-857b-a7e3b8323a53"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("88300763-b8c3-4bd2-803b-df0e43bc691e"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("7aaad5a9-4497-429f-b3dc-9b59664e2e19"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("2db130c1-4b6f-4719-b3bb-7bd8a0662d75"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("be349d14-38b5-4a13-a152-9ea055de26b1"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("5292e02d-9a7d-4a18-be14-86c2f3351cac"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("97021474-1218-4d21-9115-4b9b2726e38b"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("56f68355-7b1b-49e4-829c-1a4d47fc93de"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("3017c8cb-f9e4-41c8-ac2e-f03d23fe944a"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("619a08e5-f6cb-4515-8b4d-a094d19277d8"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("cb8b4e96-7026-4485-8b52-3145b5289350"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "RolePermissions",
|
||||
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
|
||||
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
|
||||
column: "Id",
|
||||
value: new Guid("f0f57dfd-21a5-4a0b-a77e-e641f00300fa"));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,5 +13,8 @@ namespace Distribution.Models
|
||||
public string CurrencyCode { get; set; } = "LKR";
|
||||
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>Code of the mirrored General Ledger account (child of GL Cash in Hand / Bank Accounts). Null until synced.</summary>
|
||||
public string? GlAccountCode { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public enum GlPostingStatus { Pending, Posted, Failed }
|
||||
|
||||
/// <summary>
|
||||
/// One row per journal entry Distribution sends to the General Ledger service. (Event, SourceId) is unique,
|
||||
/// so a business event can never be posted twice; Failed rows keep their payload and can be retried.
|
||||
/// </summary>
|
||||
public class GlPostingLog : Template
|
||||
{
|
||||
public string Event { get; set; } = string.Empty;
|
||||
public Guid SourceId { get; set; }
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public GlPostingStatus Status { get; set; } = GlPostingStatus.Pending;
|
||||
|
||||
/// <summary>The journal entry body (without periodId, which is resolved from EntryDate at send time).</summary>
|
||||
public string PayloadJson { get; set; } = string.Empty;
|
||||
|
||||
public string? GlJournalNo { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public int Attempts { get; set; }
|
||||
public DateTime? LastAttemptAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverProfile;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.ShopPO;
|
||||
@@ -141,6 +142,8 @@ builder.Services.AddScoped<ChequeBookService>();
|
||||
builder.Services.AddScoped<ReceivedChequeService>();
|
||||
builder.Services.AddScoped<SupplierPaymentService>();
|
||||
builder.Services.AddScoped<SysConfigService>();
|
||||
builder.Services.AddScoped<GlSetupService>();
|
||||
builder.Services.AddScoped<GlPostingService>();
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -150,6 +153,13 @@ builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
|
||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["GeneralLedger:BaseUrl"] ?? throw new InvalidOperationException("GeneralLedger:BaseUrl is not configured.");
|
||||
var apiKey = builder.Configuration["GeneralLedger:ApiKey"] ?? throw new InvalidOperationException("GeneralLedger:ApiKey is not configured.");
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
|
||||
});
|
||||
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
||||
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"Urls": "http://localhost:8802",
|
||||
"ConnectionStrings": {
|
||||
// "DefaultConnection": "Server=localhost;Port=3306;Database=distribution;User=root;Password="
|
||||
// live server
|
||||
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
|
||||
},
|
||||
"GeneralLedger": {
|
||||
"BaseUrl": "http://localhost:6704/api/",
|
||||
"ApiKey": "ff303a4b1919092501a2ab2b8725709177bbb8f0025c58d39de4ad678e2b4eda"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { usePermission } from '@/hooks/use-permission'
|
||||
import { getApiError } from '@/lib/api/index'
|
||||
import { bankAccounts } from '@/lib/api/bank-accounts'
|
||||
import { generalLedger, type GlPostingRow, type GlStatus, type YearEndPreview } from '@/lib/api/finance-reports'
|
||||
import type { BankAccountResponse } from '@/types/payments'
|
||||
|
||||
const ACCOUNT_LABELS: Record<string, string> = {
|
||||
'Gl.Account.CashEquivalents': 'Cash and cash equivalents',
|
||||
'Gl.Account.CashInHand': 'Cash in hand',
|
||||
'Gl.Account.BankAccounts': 'Bank accounts',
|
||||
'Gl.Account.ChequesInHand': 'Cheques in hand',
|
||||
'Gl.Account.Receivable': 'Trade receivables',
|
||||
'Gl.Account.Inventory': 'Inventory',
|
||||
'Gl.Account.InputVat': 'Input VAT',
|
||||
'Gl.Account.InputNbt': 'Input NBT',
|
||||
'Gl.Account.Payable': 'Trade payables',
|
||||
'Gl.Account.ChequesIssued': 'Cheques issued (uncleared)',
|
||||
'Gl.Account.Equity': "Owner's equity (header)",
|
||||
'Gl.Account.ShareCapital': "Share capital / owner's capital",
|
||||
'Gl.Account.RetainedEarnings': 'Retained earnings',
|
||||
'Gl.Account.Drawings': 'Drawings and dividends',
|
||||
'Gl.Account.GeneralReserve': 'General reserve',
|
||||
'Gl.Account.RevaluationReserve': 'Revaluation reserve',
|
||||
'Gl.Account.Sales': 'Sales revenue',
|
||||
}
|
||||
|
||||
// Opening balance form rows. Amounts are each account's normal balance and default to 0 (zero rows are skipped).
|
||||
const OPENING_ROWS: { key: string; label: string; group: 'Assets' | 'Liabilities' | 'Equity' }[] = [
|
||||
{ key: 'ChequesInHand', label: 'Cheques in hand', group: 'Assets' },
|
||||
{ key: 'Receivable', label: 'Trade receivables', group: 'Assets' },
|
||||
{ key: 'Inventory', label: 'Inventory', group: 'Assets' },
|
||||
{ key: 'InputVat', label: 'Input VAT', group: 'Assets' },
|
||||
{ key: 'InputNbt', label: 'Input NBT', group: 'Assets' },
|
||||
{ key: 'Payable', label: 'Trade payables', group: 'Liabilities' },
|
||||
{ key: 'ChequesIssued', label: 'Cheques issued (uncleared)', group: 'Liabilities' },
|
||||
{ key: 'ShareCapital', label: "Share capital / owner's capital", group: 'Equity' },
|
||||
{ key: 'RetainedEarnings', label: 'Retained earnings', group: 'Equity' },
|
||||
{ key: 'GeneralReserve', label: 'General reserve', group: 'Equity' },
|
||||
{ key: 'RevaluationReserve', label: 'Revaluation reserve', group: 'Equity' },
|
||||
{ key: 'Drawings', label: 'Drawings and dividends (reduces equity)', group: 'Equity' },
|
||||
]
|
||||
|
||||
const toNum = (v: string) => (v.trim() === '' ? 0 : Number(v))
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
async function fetchState() {
|
||||
const status = await generalLedger.status()
|
||||
const waiting = status.failed > 0
|
||||
? [...(await generalLedger.postings('Failed')), ...(await generalLedger.postings('Pending'))]
|
||||
: []
|
||||
return { status, waiting }
|
||||
}
|
||||
|
||||
export function GeneralLedgerClient() {
|
||||
const allowed = usePermission('gl.manage')
|
||||
const [status, setStatus] = useState<GlStatus | null>(null)
|
||||
const [failed, setFailed] = useState<GlPostingRow[]>([])
|
||||
const [busy, setBusy] = useState<'setup' | 'retry' | 'opening' | 'preview' | 'close' | null>(null)
|
||||
const [closeYear, setCloseYear] = useState(new Date().getFullYear() - 1)
|
||||
const [preview, setPreview] = useState<YearEndPreview | null>(null)
|
||||
const [cashBank, setCashBank] = useState<BankAccountResponse[]>([])
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({})
|
||||
const [openingDate, setOpeningDate] = useState(`${new Date().getFullYear()}-01-01`)
|
||||
|
||||
const apply = useCallback((d: { status: GlStatus; waiting: GlPostingRow[] }) => {
|
||||
setStatus(d.status)
|
||||
setFailed(d.waiting)
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(
|
||||
() => fetchState().then(apply).catch(err => toast.add({ title: 'Could not load General Ledger status', description: getApiError(err), type: 'error' })),
|
||||
[apply],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return
|
||||
fetchState()
|
||||
.then(apply)
|
||||
.catch(err => toast.add({ title: 'Could not load General Ledger status', description: getApiError(err), type: 'error' }))
|
||||
}, [allowed, apply])
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) bankAccounts.list().then(setCashBank).catch(() => setCashBank([]))
|
||||
}, [allowed])
|
||||
|
||||
const rowsTotal = (group: 'Assets' | 'Liabilities' | 'Equity') =>
|
||||
OPENING_ROWS.filter(r => r.group === group && r.key !== 'Drawings').reduce((t, r) => t + toNum(amounts[r.key] ?? ''), 0)
|
||||
const bankTotal = cashBank.reduce((t, b) => t + toNum(amounts[`bank:${b.accountId}`] ?? ''), 0)
|
||||
// Drawings is debit-side (reduces equity), so it sits with the assets side of the balance check.
|
||||
const debitsTotal = rowsTotal('Assets') + bankTotal + toNum(amounts.Drawings ?? '')
|
||||
const creditsTotal = rowsTotal('Liabilities') + rowsTotal('Equity')
|
||||
const difference = debitsTotal - creditsTotal
|
||||
|
||||
async function loadPreview() {
|
||||
setBusy('preview')
|
||||
try {
|
||||
setPreview(await generalLedger.yearEndPreview(closeYear))
|
||||
} catch (err) {
|
||||
setPreview(null)
|
||||
toast.add({ title: 'Could not load the year-end preview', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function runClose() {
|
||||
setBusy('close')
|
||||
try {
|
||||
const result = await generalLedger.closeYear(closeYear)
|
||||
toast.add({
|
||||
title: `${closeYear} closed`,
|
||||
description: `Journal ${result.glJournalNo ?? ''} posted, ${result.periodsLocked} periods locked`,
|
||||
type: 'success',
|
||||
})
|
||||
setPreview(null)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Could not close the year', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function postOpening() {
|
||||
setBusy('opening')
|
||||
try {
|
||||
const lines = [
|
||||
...cashBank.map(b => ({ account: 'BankAccount', bankAccountId: b.accountId, amount: toNum(amounts[`bank:${b.accountId}`] ?? '') })),
|
||||
...OPENING_ROWS.map(r => ({ account: r.key, amount: toNum(amounts[r.key] ?? '') })),
|
||||
]
|
||||
const result = await generalLedger.postOpeningBalances(openingDate, 'Opening balances', lines)
|
||||
toast.add({ title: 'Opening balances posted', description: result.glJournalNo ? `Journal ${result.glJournalNo}` : undefined, type: 'success' })
|
||||
setAmounts({})
|
||||
await reload()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Could not post opening balances', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function runSetup() {
|
||||
setBusy('setup')
|
||||
try {
|
||||
const result = await generalLedger.setup()
|
||||
toast.add({
|
||||
title: 'General Ledger set up',
|
||||
description: result.actions.length ? `${result.actions.length} change(s) applied.` : 'Nothing to change — already up to date.',
|
||||
type: 'success',
|
||||
})
|
||||
await reload()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Setup failed', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
setBusy('retry')
|
||||
try {
|
||||
const result = await generalLedger.retry()
|
||||
toast.add({ title: 'Retry finished', description: `${result.posted} posting(s) sent to the General Ledger.`, type: 'success' })
|
||||
await reload()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Retry failed', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <p className="text-sm text-muted-foreground">You do not have permission to manage the General Ledger link.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">General Ledger Integration</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Sales, goods receipts, payments and cheque movements are mirrored into the General Ledger so financial reports can be produced.
|
||||
Sales, goods receipts and payments made before setup are not posted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{([
|
||||
['General Ledger service', status.glReachable ? 'Reachable' : 'Unreachable'],
|
||||
['Setup', status.setupDone ? 'Done' : 'Not run'],
|
||||
['Posted entries', String(status.posted)],
|
||||
['Waiting / failed', String(status.failed)],
|
||||
] as const).map(([label, value]) => (
|
||||
<Card key={label} className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-1">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-xl">{value}</CardTitle>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={runSetup} disabled={busy !== null}>
|
||||
{busy === 'setup' ? 'Running…' : status?.setupDone ? 'Re-run setup' : 'Run setup'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={retry} disabled={busy !== null || !status || status.failed === 0}>
|
||||
{busy === 'retry' ? 'Retrying…' : 'Retry failed postings'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status?.setupDone && (
|
||||
<Card className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Close financial year</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moves the year's sales and expenses into Retained Earnings with one closing entry and locks the year's periods.
|
||||
Anything dated in that year afterwards will be rejected by the General Ledger. Reports for the year still show its own figures.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input className="w-32" type="number" value={closeYear} onChange={e => { setCloseYear(Number(e.target.value)); setPreview(null) }} />
|
||||
<Button size="sm" variant="outline" onClick={loadPreview} disabled={busy !== null}>
|
||||
{busy === 'preview' ? 'Checking…' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
{preview && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
Income {fmt(preview.totalIncome)} · Expenses {fmt(preview.totalExpenses)} · Net {preview.netProfit >= 0 ? 'profit' : 'loss'} {fmt(Math.abs(preview.netProfit))}
|
||||
</p>
|
||||
{preview.reason && <p className="text-amber-600">{preview.reason}</p>}
|
||||
<Button size="sm" onClick={runClose} disabled={busy !== null || !preview.closable}>
|
||||
{busy === 'close' ? 'Closing…' : `Close ${preview.year}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status && status.closedYears.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">Closed years: {status.closedYears.join(', ')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status?.setupDone && !status.openingBalancePosted && (
|
||||
<div className="rounded-3xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-foreground">
|
||||
<p className="font-semibold">Opening balances have not been entered.</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
The ledger and every financial report start from zero. If the business already has cash, bank balances, stock, receivables,
|
||||
payables or capital, enter them below (all amounts default to 0). Equity accounts such as Share Capital only appear in the
|
||||
Balance Sheet once they hold a balance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.setupDone && (
|
||||
<Card className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Opening balances</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">As of</span>
|
||||
<Input className="w-44" type="date" value={openingDate} onChange={e => setOpeningDate(e.target.value)} />
|
||||
</div>
|
||||
{(['Assets', 'Liabilities', 'Equity'] as const).map(group => (
|
||||
<div key={group} className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{group}</p>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{group === 'Assets' && cashBank.map(b => (
|
||||
<label key={b.accountId} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span>{b.accountType === 'Bank' && b.bankName ? `${b.bankName} - ${b.accountName}` : b.accountName}</span>
|
||||
<Input className="w-40 text-right" type="number" min="0" placeholder="0" value={amounts[`bank:${b.accountId}`] ?? ''}
|
||||
onChange={e => setAmounts(a => ({ ...a, [`bank:${b.accountId}`]: e.target.value }))} />
|
||||
</label>
|
||||
))}
|
||||
{OPENING_ROWS.filter(r => r.group === group).map(r => (
|
||||
<label key={r.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span>{r.label}</span>
|
||||
<Input className="w-40 text-right" type="number" min="0" placeholder="0" value={amounts[r.key] ?? ''}
|
||||
onChange={e => setAmounts(a => ({ ...a, [r.key]: e.target.value }))} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className={`text-sm ${difference === 0 ? 'text-muted-foreground' : 'text-amber-600'}`}>
|
||||
Debit side (assets, drawings) {fmt(debitsTotal)} · Credit side (liabilities, equity) {fmt(creditsTotal)}
|
||||
{difference !== 0 && ` · difference ${fmt(Math.abs(difference))} (adjust share capital or retained earnings)`}
|
||||
</p>
|
||||
<Button size="sm" onClick={postOpening} disabled={busy !== null || (debitsTotal === 0 && creditsTotal === 0) || difference !== 0}>
|
||||
{busy === 'opening' ? 'Posting…' : 'Post opening balances'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status?.setupDone && (
|
||||
<Card className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-2">
|
||||
<h2 className="text-base font-semibold text-foreground">Account mapping</h2>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow><TableHead>Purpose</TableHead><TableHead>GL account code</TableHead></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(status.mapping).map(([key, code]) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell>{ACCOUNT_LABELS[key] ?? key}</TableCell>
|
||||
<TableCell>{code ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{failed.length > 0 && (
|
||||
<Card className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-2">
|
||||
<h2 className="text-base font-semibold text-foreground">Postings waiting to be sent</h2>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow><TableHead>Event</TableHead><TableHead>Reference</TableHead><TableHead>Attempts</TableHead><TableHead>Error</TableHead></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{failed.map(p => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.event}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.sourceId}</TableCell>
|
||||
<TableCell>{p.attempts}</TableCell>
|
||||
<TableCell className="text-red-600">{p.error ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GeneralLedgerClient } from './_components/general-ledger-client'
|
||||
|
||||
export default function GeneralLedgerPage() {
|
||||
return <GeneralLedgerClient />
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { usePermission } from '@/hooks/use-permission'
|
||||
import { getApiError } from '@/lib/api/index'
|
||||
import { financeReports, type FileFormat, type GlAccountOption, type FinanceReportParams, type FinanceReportType } from '@/lib/api/finance-reports'
|
||||
|
||||
import { toBlocks, type Block } from './report-blocks'
|
||||
|
||||
type Filter = 'asOf' | 'range' | 'account' | 'budget'
|
||||
|
||||
const REPORTS: { type: FinanceReportType; label: string; filters: Filter[]; hint: string }[] = [
|
||||
{ type: 'TrialBalance', label: 'Trial Balance', filters: ['asOf'], hint: 'Debit and credit balance of every ledger account as of a date.' },
|
||||
{ type: 'GeneralLedger', label: 'General Ledger', filters: ['range', 'account'], hint: 'Every posted transaction line with the account debited or credited, grouped by account.' },
|
||||
{ type: 'ProfitAndLoss', label: 'Profit & Loss', filters: ['range'], hint: 'Income and expenses for a period.' },
|
||||
{ type: 'BalanceSheet', label: 'Balance Sheet', filters: ['asOf'], hint: 'Assets, liabilities and equity as of a date.' },
|
||||
{ type: 'CashFlow', label: 'Cash Flow', filters: ['range'], hint: 'Cash movement for a period (indirect method).' },
|
||||
{ type: 'BudgetVsActual', label: 'Budget vs Actual', filters: ['budget'], hint: 'Budget lines against posted actuals. Budgets are created in the General Ledger service.' },
|
||||
{ type: 'TaxSummary', label: 'Tax Summary', filters: ['range'], hint: 'Income tax computation for a period.' },
|
||||
]
|
||||
|
||||
const ALL_ACCOUNTS = 'all'
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const yearStart = () => `${new Date().getFullYear()}-01-01`
|
||||
|
||||
export function FinanceReportClient() {
|
||||
const allowed = usePermission('finance-reports.view')
|
||||
const [type, setType] = useState<FinanceReportType>('TrialBalance')
|
||||
const [asOfDate, setAsOfDate] = useState(today())
|
||||
const [periodStart, setPeriodStart] = useState(yearStart())
|
||||
const [periodEnd, setPeriodEnd] = useState(today())
|
||||
const [accountCode, setAccountCode] = useState(ALL_ACCOUNTS)
|
||||
const [accounts, setAccounts] = useState<GlAccountOption[]>([])
|
||||
const [budgetId, setBudgetId] = useState('')
|
||||
const [blocks, setBlocks] = useState<Block[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busy, setBusy] = useState<FileFormat | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return
|
||||
financeReports.accounts().then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [allowed])
|
||||
|
||||
const report = REPORTS.find(r => r.type === type)!
|
||||
|
||||
function params(): FinanceReportParams {
|
||||
const p: FinanceReportParams = {}
|
||||
if (report.filters.includes('asOf')) p.asOfDate = asOfDate
|
||||
if (report.filters.includes('range')) { p.periodStart = periodStart; p.periodEnd = periodEnd }
|
||||
if (report.filters.includes('account') && accountCode !== ALL_ACCOUNTS) p.accountCode = accountCode
|
||||
if (report.filters.includes('budget')) p.budgetId = Number(budgetId) || undefined
|
||||
return p
|
||||
}
|
||||
|
||||
function changeType(next: FinanceReportType) {
|
||||
setType(next)
|
||||
setBlocks(null)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await financeReports.get(type, params())
|
||||
setBlocks(toBlocks(type, data))
|
||||
} catch (err) {
|
||||
setBlocks(null)
|
||||
toast.add({ title: 'Could not load report', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function download(format: FileFormat) {
|
||||
setBusy(format)
|
||||
try {
|
||||
await financeReports.download(type, params(), format)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Download failed', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <p className="text-sm text-muted-foreground">You do not have permission to view financial reports.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Financial Reports</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Produced by the General Ledger from sales, purchases and payments. Cash, bank and cheque balances are mirrored from the Payments module.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{REPORTS.map(r => (
|
||||
<Button key={r.type} size="sm" variant={type === r.type ? 'default' : 'outline'} onClick={() => changeType(r.type)}>
|
||||
{r.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{report.hint}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{report.filters.includes('asOf') && (
|
||||
<Input className="w-44" type="date" value={asOfDate} onChange={e => setAsOfDate(e.target.value)} aria-label="As of date" />
|
||||
)}
|
||||
{report.filters.includes('range') && (
|
||||
<>
|
||||
<Input className="w-44" type="date" value={periodStart} onChange={e => setPeriodStart(e.target.value)} aria-label="From" />
|
||||
<Input className="w-44" type="date" value={periodEnd} onChange={e => setPeriodEnd(e.target.value)} aria-label="To" />
|
||||
</>
|
||||
)}
|
||||
{report.filters.includes('account') && (
|
||||
<Select value={accountCode} onValueChange={v => setAccountCode(v ?? ALL_ACCOUNTS)}>
|
||||
<SelectTrigger className="w-72"><SelectValue placeholder="Account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ACCOUNTS}>All accounts</SelectItem>
|
||||
{accounts.map(a => <SelectItem key={a.accountCode} value={a.accountCode}>{a.accountCode} — {a.accountName}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{report.filters.includes('budget') && (
|
||||
<Input className="w-40" type="number" placeholder="Budget id" value={budgetId} onChange={e => setBudgetId(e.target.value)} />
|
||||
)}
|
||||
<Button size="sm" onClick={run} disabled={loading}>{loading ? 'Loading…' : 'Run report'}</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => download('Pdf')}>
|
||||
{busy === 'Pdf' ? 'Downloading…' : 'Download PDF'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => download('Csv')}>
|
||||
{busy === 'Csv' ? 'Downloading…' : 'Download CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blocks === null ? (
|
||||
<p className="text-sm text-muted-foreground">Choose the filters and run the report.</p>
|
||||
) : blocks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No data for this selection.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{blocks.map((b, i) => <BlockTable key={i} block={b} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTable({ block }: { block: Block }) {
|
||||
const numeric = new Set(block.numericCols)
|
||||
return (
|
||||
<Card className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-2">
|
||||
{block.title && <h2 className="text-base font-semibold text-foreground">{block.title}</h2>}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{block.headers.map((h, c) => (
|
||||
<TableHead key={c} className={numeric.has(c) ? 'text-right' : undefined}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{block.rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={block.headers.length} className="text-muted-foreground">No entries.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
block.rows.map((row, r) => (
|
||||
<TableRow key={r}>
|
||||
{row.map((cell, c) => (
|
||||
<TableCell key={c} className={numeric.has(c) ? 'text-right tabular-nums' : undefined}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{block.footer && (
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
{block.footer.map((cell, c) => (
|
||||
<TableCell key={c} className={`font-semibold ${numeric.has(c) ? 'text-right tabular-nums' : ''}`}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { FinanceReportType } from '@/lib/api/finance-reports'
|
||||
|
||||
/** A table the report page renders; every GL report is converted into one or more of these. */
|
||||
export interface Block {
|
||||
title?: string
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
footer?: string[]
|
||||
numericCols: number[]
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>
|
||||
|
||||
const money = (v: unknown) =>
|
||||
Number(v ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const text = (v: unknown) => (v == null ? '' : String(v))
|
||||
|
||||
const sum = (rows: Obj[], key: string) => rows.reduce((t, r) => t + Number(r[key] ?? 0), 0)
|
||||
|
||||
const humanize = (key: string) =>
|
||||
key.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()).replace(/\bApit\b/, 'APIT').replace(/\bWht\b/, 'WHT')
|
||||
|
||||
function summary(title: string | undefined, entries: [string, unknown][]): Block {
|
||||
return { title, headers: ['', 'Amount'], rows: entries.map(([k, v]) => [k, money(v)]), numericCols: [1] }
|
||||
}
|
||||
|
||||
function section(title: string, s: Obj | undefined, amountKey: string, totalLabel: string): Block | null {
|
||||
const lines = (s?.lines as Obj[] | undefined) ?? []
|
||||
if (lines.length === 0 && !Number(s?.total ?? 0)) return null
|
||||
return {
|
||||
title,
|
||||
headers: ['Code', 'Account', 'Amount'],
|
||||
rows: lines.map(l => [text(l.accountCode), text(l.accountName), money(l[amountKey])]),
|
||||
footer: ['', totalLabel, money(s?.total)],
|
||||
numericCols: [2],
|
||||
}
|
||||
}
|
||||
|
||||
const compact = (blocks: (Block | null)[]) => blocks.filter((b): b is Block => b !== null)
|
||||
|
||||
export function toBlocks(type: FinanceReportType, data: unknown): Block[] {
|
||||
switch (type) {
|
||||
case 'TrialBalance': {
|
||||
const rows = (data as Obj[]) ?? []
|
||||
return [{
|
||||
headers: ['Code', 'Account', 'Debit', 'Credit'],
|
||||
rows: rows.map(r => [text(r.accountCode), text(r.accountName), money(r.debit), money(r.credit)]),
|
||||
footer: ['', 'Total', money(sum(rows, 'debit')), money(sum(rows, 'credit'))],
|
||||
numericCols: [2, 3],
|
||||
}]
|
||||
}
|
||||
|
||||
case 'GeneralLedger': {
|
||||
const rows = (data as Obj[]) ?? []
|
||||
return [{
|
||||
headers: ['Date', 'Journal', 'Account', 'Narration', 'Debit', 'Credit'],
|
||||
rows: rows.map(r => [
|
||||
text(r.entryDate), text(r.journalNo), `${text(r.accountCode)} ${text(r.accountName)}`, text(r.narration),
|
||||
r.debitAmount ? money(r.debitAmount) : '', r.creditAmount ? money(r.creditAmount) : '',
|
||||
]),
|
||||
footer: ['', '', '', 'Total', money(sum(rows, 'debitAmount')), money(sum(rows, 'creditAmount'))],
|
||||
numericCols: [4, 5],
|
||||
}]
|
||||
}
|
||||
|
||||
case 'ProfitAndLoss': {
|
||||
const d = (data as Obj) ?? {}
|
||||
return compact([
|
||||
section('Sales', d.sales as Obj, 'amount', 'Total sales'),
|
||||
section('Cost of sales', d.costOfSales as Obj, 'amount', 'Total cost of sales'),
|
||||
summary(undefined, [['Gross profit', d.grossProfit]]),
|
||||
section('Other income', d.otherIncome as Obj, 'amount', 'Total other income'),
|
||||
section('Distribution expenses', d.distributionExpenses as Obj, 'amount', 'Total'),
|
||||
section('Administration expenses', d.administrationExpenses as Obj, 'amount', 'Total'),
|
||||
section('Other expenses', d.otherExpenses as Obj, 'amount', 'Total'),
|
||||
section('Financial expenses', d.financialExpenses as Obj, 'amount', 'Total'),
|
||||
section('Unclassified accounts', d.unclassified as Obj, 'amount', 'Total'),
|
||||
summary(undefined, [['Net profit for the period', d.netProfitForPeriod]]),
|
||||
])
|
||||
}
|
||||
|
||||
case 'BalanceSheet': {
|
||||
const d = (data as Obj) ?? {}
|
||||
return compact([
|
||||
section('Non-current assets', d.nonCurrentAssets as Obj, 'balance', 'Total'),
|
||||
section('Current assets', d.currentAssets as Obj, 'balance', 'Total'),
|
||||
section('Unclassified assets', d.unclassifiedAssets as Obj, 'balance', 'Total'),
|
||||
summary(undefined, [['Total assets', d.totalAssets]]),
|
||||
section('Equity', d.equity as Obj, 'balance', 'Total equity'),
|
||||
section('Non-current liabilities', d.nonCurrentLiabilities as Obj, 'balance', 'Total'),
|
||||
section('Current liabilities', d.currentLiabilities as Obj, 'balance', 'Total'),
|
||||
section('Unclassified liabilities', d.unclassifiedLiabilities as Obj, 'balance', 'Total'),
|
||||
summary(undefined, [['Total equity and liabilities', d.totalEquityAndLiabilities]]),
|
||||
])
|
||||
}
|
||||
|
||||
case 'CashFlow': {
|
||||
const d = (data as Obj) ?? {}
|
||||
const op = (d.operatingActivities as Obj) ?? {}
|
||||
const adjustments = (op.nonCashAdjustments as Obj[]) ?? []
|
||||
const changes = (op.workingCapitalChanges as Obj[]) ?? []
|
||||
const lines = (a: unknown) => ((a as Obj)?.lines as Obj[]) ?? []
|
||||
return [
|
||||
{
|
||||
title: 'Operating activities',
|
||||
headers: ['Item', 'Amount'],
|
||||
rows: [
|
||||
['Profit for the period', money(op.profitForPeriod)],
|
||||
...adjustments.map(a => [text(a.description), money(a.amount)]),
|
||||
...changes.map(c => [`${text(c.accountName)} (${text(c.direction)})`, money(c.changeAmount)]),
|
||||
],
|
||||
footer: ['Net cash from operating activities', money(op.netCashFromOperatingActivities)],
|
||||
numericCols: [1],
|
||||
},
|
||||
{
|
||||
title: 'Investing activities',
|
||||
headers: ['Item', 'Amount'],
|
||||
rows: lines(d.investingActivities).map(l => [text(l.accountName), money(l.amount)]),
|
||||
footer: ['Net cash from investing activities', money((d.investingActivities as Obj)?.netCashFromInvestingActivities)],
|
||||
numericCols: [1],
|
||||
},
|
||||
{
|
||||
title: 'Financing activities',
|
||||
headers: ['Item', 'Amount'],
|
||||
rows: lines(d.financingActivities).map(l => [text(l.accountName), money(l.amount)]),
|
||||
footer: ['Net cash from financing activities', money((d.financingActivities as Obj)?.netCashFromFinancingActivities)],
|
||||
numericCols: [1],
|
||||
},
|
||||
summary(undefined, [
|
||||
['Net increase / decrease in cash', d.netIncreaseDecreaseInCash],
|
||||
['Opening cash balance', d.openingCashBalance],
|
||||
['Closing cash balance', d.closingCashBalance],
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
case 'BudgetVsActual': {
|
||||
const rows = (data as Obj[]) ?? []
|
||||
return [{
|
||||
headers: ['Account', 'Period', 'Budgeted', 'Actual', 'Variance'],
|
||||
rows: rows.map(r => [`${text(r.accountCode)} ${text(r.accountName)}`, text(r.periodId), money(r.budgetedAmount), money(r.actualAmount), money(r.variance)]),
|
||||
numericCols: [2, 3, 4],
|
||||
}]
|
||||
}
|
||||
|
||||
case 'TaxSummary': {
|
||||
const d = (data as Obj) ?? {}
|
||||
const entries = Object.entries(d).filter(([k]) => k !== 'periodStart' && k !== 'periodEnd')
|
||||
return [summary(`Income tax computation (${text(d.periodStart)} to ${text(d.periodEnd)})`, entries.map(([k, v]) => [humanize(k), v]))]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FinanceReportClient } from './_components/finance-report-client'
|
||||
|
||||
export default function FinanceReportPage() {
|
||||
return <FinanceReportClient />
|
||||
}
|
||||
@@ -134,6 +134,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ title: 'Bank Accounts', href: '/payments/bank-accounts', icon: BankIcon, description: 'Cash and bank accounts used for payments and deposits' },
|
||||
{ title: 'Cheque Books', href: '/payments/cheque-books', icon: ClipboardListIcon, description: 'Cheque books issued from the company’s own supply' },
|
||||
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
|
||||
{ title: 'GL Integration', href: '/configuration/general-ledger', icon: Settings01Icon, description: 'Set up the General Ledger link and retry failed postings', permission: 'gl.manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -179,6 +180,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
|
||||
{ title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' },
|
||||
{ title: 'Financial Reports', href: '/reports/finance', icon: DollarSquareIcon, description: 'Trial balance, ledger, profit & loss, balance sheet and cash flow from the General Ledger', permission: 'finance-reports.view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -107,6 +107,14 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
'roles.view',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
label: 'Finance',
|
||||
codes: [
|
||||
'finance-reports.view',
|
||||
'gl.manage',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export type FinanceReportType =
|
||||
| 'TrialBalance'
|
||||
| 'GeneralLedger'
|
||||
| 'ProfitAndLoss'
|
||||
| 'BalanceSheet'
|
||||
| 'CashFlow'
|
||||
| 'BudgetVsActual'
|
||||
| 'TaxSummary'
|
||||
|
||||
export interface FinanceReportParams {
|
||||
asOfDate?: string
|
||||
periodStart?: string
|
||||
periodEnd?: string
|
||||
accountCode?: string
|
||||
budgetId?: number
|
||||
}
|
||||
|
||||
export type FileFormat = 'Pdf' | 'Csv'
|
||||
|
||||
const cleanParams = (params: FinanceReportParams) =>
|
||||
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined && v !== ''))
|
||||
|
||||
// Reports come from the General Ledger service through the Distribution backend (/reports/finance/{type}).
|
||||
export interface GlAccountOption {
|
||||
accountCode: string
|
||||
accountName: string
|
||||
}
|
||||
|
||||
export const financeReports = {
|
||||
/** Active GL accounts for the account dropdown. */
|
||||
accounts: () => api.get<GlAccountOption[]>('/reports/finance/accounts').then(r => r.data),
|
||||
|
||||
get: (type: FinanceReportType, params: FinanceReportParams) =>
|
||||
api.get<unknown>(`/reports/finance/${type}`, { params: cleanParams(params) }).then(r => r.data),
|
||||
|
||||
/** Downloads the GL-rendered PDF or CSV and hands it to the browser. */
|
||||
async download(type: FinanceReportType, params: FinanceReportParams, format: FileFormat) {
|
||||
const res = await api.get<Blob>(`/reports/finance/${type}`, {
|
||||
params: { ...cleanParams(params), outputFormat: format },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const disposition = String(res.headers['content-disposition'] ?? '')
|
||||
const name = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)?.[1] ?? `${type}.${format.toLowerCase()}`
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = decodeURIComponent(name)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
|
||||
export interface GlStatus {
|
||||
glReachable: boolean
|
||||
setupDone: boolean
|
||||
openingBalancePosted: boolean
|
||||
closedYears: number[]
|
||||
mapping: Record<string, string | null>
|
||||
posted: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface GlPostingRow {
|
||||
id: string
|
||||
event: string
|
||||
sourceId: string
|
||||
reference: string
|
||||
status: 'Pending' | 'Posted' | 'Failed'
|
||||
glJournalNo: string | null
|
||||
error: string | null
|
||||
attempts: number
|
||||
lastAttemptAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface OpeningBalanceLine {
|
||||
/** A key such as 'Inventory' or 'ShareCapital', or 'BankAccount' together with bankAccountId. */
|
||||
account: string
|
||||
bankAccountId?: string
|
||||
/** The account's normal balance (>= 0). Debit/credit side is decided by the backend. */
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface YearEndPreview {
|
||||
year: number
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalIncome: number
|
||||
totalExpenses: number
|
||||
netProfit: number
|
||||
closable: boolean
|
||||
reason: string | null
|
||||
alreadyClosed: boolean
|
||||
}
|
||||
|
||||
export const generalLedger = {
|
||||
yearEndPreview: (year: number) =>
|
||||
api.get<YearEndPreview>('/gl/year-end/preview', { params: { year } }).then(r => r.data),
|
||||
closeYear: (year: number) =>
|
||||
api.post<{ year: number; glJournalNo: string | null; netProfit: number; periodsLocked: number }>('/gl/year-end/close', { year }).then(r => r.data),
|
||||
postOpeningBalances: (date: string, narration: string, lines: OpeningBalanceLine[]) =>
|
||||
api.post<{ status: string; glJournalNo: string | null }>('/gl/opening-balances', { date, narration, lines }).then(r => r.data),
|
||||
status: () => api.get<GlStatus>('/gl/status').then(r => r.data),
|
||||
setup: (year?: number) => api.post<{ done: boolean; actions: string[] }>('/gl/setup', { year }).then(r => r.data),
|
||||
postings: (status?: GlPostingRow['status']) =>
|
||||
api.get<GlPostingRow[]>('/gl/postings', { params: { status } }).then(r => r.data),
|
||||
retry: () => api.post<{ posted: number }>('/gl/postings/retry').then(r => r.data),
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||
export { stock } from './stock'
|
||||
export { productReports, stockReports, dashboardReports } from './reports'
|
||||
export { financeReports, generalLedger } from './finance-reports'
|
||||
export { shopPos, invoices, payments } from './sales'
|
||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
|
||||
|
||||
Reference in New Issue
Block a user