feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management. - Introduced dedicated GL client for API interactions, handling response envelopes and error management. - Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report. - Implemented CSV download functionality alongside existing PDF downloads for all report screens. - Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view. - Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section. - Updated RBAC navigation to include new permissions and sub-navigation items for the added features. - Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes. - Addressed various bugs and presentation issues, enhancing user experience across the new module.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic reverse proxy into the external General Ledger service — forwards every
|
||||
/// method/path/query/body under this prefix verbatim via
|
||||
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
|
||||
/// body) unchanged. No endpoint-specific shape lives here; see
|
||||
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
|
||||
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
|
||||
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
|
||||
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
|
||||
|
||||
[HttpPost("{**path}")]
|
||||
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
|
||||
|
||||
[HttpPut("{**path}")]
|
||||
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
|
||||
|
||||
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
|
||||
{
|
||||
var body = method == HttpMethod.Get ? null : Request.Body;
|
||||
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = result.StatusCode,
|
||||
Content = result.Body,
|
||||
ContentType = result.ContentType ?? "application/json"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IGeneralLedgerClient"/>. Registered as a typed
|
||||
/// client (`AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>`) with its
|
||||
/// `BaseAddress` bound from `GeneralLedgerService:BaseUrl`. Every call attaches the
|
||||
/// shared `GeneralLedgerService:ApiKey` as `X-Api-Key` and streams the request/response
|
||||
/// body straight through, unparsed — GL's own response (status, content-type, body) is
|
||||
/// returned exactly as received; nothing here reshapes it.
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configuration) : IGeneralLedgerClient
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _apiKey = configuration["GeneralLedgerService:ApiKey"] ?? string.Empty;
|
||||
|
||||
public async Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
{
|
||||
var relativeUri = path.TrimStart('/') + queryString;
|
||||
using var request = new HttpRequestMessage(method, relativeUri);
|
||||
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
|
||||
|
||||
if (body is not null && method != HttpMethod.Get)
|
||||
{
|
||||
var content = new StreamContent(body);
|
||||
if (!string.IsNullOrEmpty(contentType))
|
||||
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
request.Content = content;
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service timed out.", 503);
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
return new GeneralLedgerResponse
|
||||
{
|
||||
StatusCode = (int)response.StatusCode,
|
||||
ContentType = response.Content.Headers.ContentType?.ToString(),
|
||||
Body = responseBody
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Raw HTTP result from the external General Ledger service — status code, content
|
||||
/// type, and body exactly as GL returned them. Deliberately un-reshaped: GL's own
|
||||
/// envelope (see the GL service's own API reference) is passed through byte-for-byte
|
||||
/// so its camelCase-success/PascalCase-error inconsistency and full decimal precision
|
||||
/// survive the hop unchanged (docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// </summary>
|
||||
public sealed class GeneralLedgerResponse
|
||||
{
|
||||
public int StatusCode { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string Body { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Typed HTTP transport to the external General Ledger service. Injects the shared
|
||||
/// `X-Api-Key` secret and forwards method/path/query/body/content-type verbatim —
|
||||
/// see docs/12-GENERAL-LEDGER-INTEGRATION.md. Internal: only
|
||||
/// <see cref="ERPCore.Services.Interfaces.IGeneralLedgerService"/> consumes this.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerClient
|
||||
{
|
||||
Task<GeneralLedgerResponse> SendAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -36,7 +36,9 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
|
||||
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
|
||||
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
|
||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,26 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
|
||||
// IDs 28-31 (not 19-22): 19-22 were already claimed by the Ledgers permissions below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
|
||||
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
|
||||
new Permission { PermissionId = 30, Code = "NAV:procurement.purchase-orders", SubNavItemId = 19 },
|
||||
new Permission { PermissionId = 31, Code = "NAV:procurement.purchase-returns", SubNavItemId = 20 },
|
||||
new Permission { PermissionId = 19, Code = "NAV:ledgers", NavItemId = 11 },
|
||||
new Permission { PermissionId = 20, Code = "NAV:ledgers.trial-balance", SubNavItemId = 9 },
|
||||
new Permission { PermissionId = 21, Code = "NAV:ledgers.balance-sheet", SubNavItemId = 10 },
|
||||
new Permission { PermissionId = 22, Code = "NAV:ledgers.general-ledger", SubNavItemId = 11 },
|
||||
new Permission { PermissionId = 23, Code = "NAV:ledgers.profit-and-loss", SubNavItemId = 12 },
|
||||
new Permission { PermissionId = 24, Code = "NAV:ledgers.cash-flow", SubNavItemId = 13 },
|
||||
new Permission { PermissionId = 25, Code = "NAV:ledgers.budget-vs-actual", SubNavItemId = 14 },
|
||||
new Permission { PermissionId = 27, Code = "NAV:ledgers.tax-report", SubNavItemId = 16 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — same PermissionId (26), just a
|
||||
// renamed Code, so any role already granted this permission keeps it.
|
||||
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
|
||||
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
|
||||
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
|
||||
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,25 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
|
||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 12, NavItemId = 11, Code = "ledgers.profit-and-loss", Label = "Profit & Loss", Href = "/dashboard/ledgers/profit-and-loss", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 13, NavItemId = 11, Code = "ledgers.cash-flow", Label = "Cash Flow", Href = "/dashboard/ledgers/cash-flow", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 14, NavItemId = 11, Code = "ledgers.budget-vs-actual", Label = "Budget vs Actual", Href = "/dashboard/ledgers/budget-vs-actual", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 16, NavItemId = 11, Code = "ledgers.tax-report", Label = "Tax Report", Href = "/dashboard/ledgers/tax-report", SortOrder = 7 },
|
||||
// Moved under the new Accounts nav item (2026-07-31) — kept the same SubNavItemId (15)
|
||||
// rather than delete+recreate, so any role that already had this permission granted
|
||||
// doesn't silently lose it just because the section it lives under changed.
|
||||
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,6 +1832,24 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
Label = "Help",
|
||||
SortOrder = 10,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 11,
|
||||
Code = "ledgers",
|
||||
Href = "/dashboard/ledgers",
|
||||
Label = "Ledgers",
|
||||
SortOrder = 11,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 12,
|
||||
Code = "accounts",
|
||||
Href = "/dashboard/accounts",
|
||||
Label = "Accounts",
|
||||
SortOrder = 12,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2285,27 +2303,99 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 19,
|
||||
PermissionId = 28,
|
||||
Code = "NAV:procurement.requisitions",
|
||||
SubNavItemId = 9
|
||||
SubNavItemId = 17
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 29,
|
||||
Code = "NAV:procurement.rfqs",
|
||||
SubNavItemId = 18
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 30,
|
||||
Code = "NAV:procurement.purchase-orders",
|
||||
SubNavItemId = 19
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 31,
|
||||
Code = "NAV:procurement.purchase-returns",
|
||||
SubNavItemId = 20
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 19,
|
||||
Code = "NAV:ledgers",
|
||||
NavItemId = 11
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 20,
|
||||
Code = "NAV:procurement.rfqs",
|
||||
SubNavItemId = 10
|
||||
Code = "NAV:ledgers.trial-balance",
|
||||
SubNavItemId = 9
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 21,
|
||||
Code = "NAV:procurement.purchase-orders",
|
||||
SubNavItemId = 11
|
||||
Code = "NAV:ledgers.balance-sheet",
|
||||
SubNavItemId = 10
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 22,
|
||||
Code = "NAV:procurement.purchase-returns",
|
||||
Code = "NAV:ledgers.general-ledger",
|
||||
SubNavItemId = 11
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 23,
|
||||
Code = "NAV:ledgers.profit-and-loss",
|
||||
SubNavItemId = 12
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 24,
|
||||
Code = "NAV:ledgers.cash-flow",
|
||||
SubNavItemId = 13
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 25,
|
||||
Code = "NAV:ledgers.budget-vs-actual",
|
||||
SubNavItemId = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 27,
|
||||
Code = "NAV:ledgers.tax-report",
|
||||
SubNavItemId = 16
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 26,
|
||||
Code = "NAV:accounts.bank-accounts",
|
||||
SubNavItemId = 15
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 32,
|
||||
Code = "NAV:accounts",
|
||||
NavItemId = 12
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 33,
|
||||
Code = "NAV:accounts.cheque-books",
|
||||
SubNavItemId = 21
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 34,
|
||||
Code = "NAV:accounts.received-cheques",
|
||||
SubNavItemId = 22
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3948,7 +4038,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 9,
|
||||
SubNavItemId = 17,
|
||||
Code = "procurement.requisitions",
|
||||
Href = "/dashboard/procurement/requisitions",
|
||||
Label = "Requisitions",
|
||||
@@ -3958,7 +4048,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 10,
|
||||
SubNavItemId = 18,
|
||||
Code = "procurement.rfqs",
|
||||
Href = "/dashboard/procurement/rfqs",
|
||||
Label = "RFQs",
|
||||
@@ -3968,7 +4058,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 11,
|
||||
SubNavItemId = 19,
|
||||
Code = "procurement.purchase-orders",
|
||||
Href = "/dashboard/procurement/purchase-orders",
|
||||
Label = "Purchase Orders",
|
||||
@@ -3978,13 +4068,113 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 12,
|
||||
SubNavItemId = 20,
|
||||
Code = "procurement.purchase-returns",
|
||||
Href = "/dashboard/procurement/purchase-returns",
|
||||
Label = "Purchase Returns",
|
||||
NavItemId = 4,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 9,
|
||||
Code = "ledgers.trial-balance",
|
||||
Href = "/dashboard/ledgers/trial-balance",
|
||||
Label = "Trial Balance",
|
||||
NavItemId = 11,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 10,
|
||||
Code = "ledgers.balance-sheet",
|
||||
Href = "/dashboard/ledgers/balance-sheet",
|
||||
Label = "Balance Sheet",
|
||||
NavItemId = 11,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 11,
|
||||
Code = "ledgers.general-ledger",
|
||||
Href = "/dashboard/ledgers/general-ledger",
|
||||
Label = "General Ledger",
|
||||
NavItemId = 11,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 12,
|
||||
Code = "ledgers.profit-and-loss",
|
||||
Href = "/dashboard/ledgers/profit-and-loss",
|
||||
Label = "Profit & Loss",
|
||||
NavItemId = 11,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 13,
|
||||
Code = "ledgers.cash-flow",
|
||||
Href = "/dashboard/ledgers/cash-flow",
|
||||
Label = "Cash Flow",
|
||||
NavItemId = 11,
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 14,
|
||||
Code = "ledgers.budget-vs-actual",
|
||||
Href = "/dashboard/ledgers/budget-vs-actual",
|
||||
Label = "Budget vs Actual",
|
||||
NavItemId = 11,
|
||||
SortOrder = 6,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 16,
|
||||
Code = "ledgers.tax-report",
|
||||
Href = "/dashboard/ledgers/tax-report",
|
||||
Label = "Tax Report",
|
||||
NavItemId = 11,
|
||||
SortOrder = 7,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 15,
|
||||
Code = "accounts.bank-accounts",
|
||||
Href = "/dashboard/accounts/bank-accounts",
|
||||
Label = "Cash / Bank Accounts",
|
||||
NavItemId = 12,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 21,
|
||||
Code = "accounts.cheque-books",
|
||||
Href = "/dashboard/accounts/cheque-books",
|
||||
Label = "Cheque Books",
|
||||
NavItemId = 12,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 22,
|
||||
Code = "accounts.received-cheques",
|
||||
Href = "/dashboard/accounts/received-cheques",
|
||||
Label = "Received Cheques",
|
||||
NavItemId = 12,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -51,6 +52,15 @@ builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
||||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||||
|
||||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
|
||||
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
||||
c.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
||||
|
||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private readonly IGeneralLedgerClient _client;
|
||||
|
||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point into the external General Ledger service — the one function
|
||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// No business logic lives here yet; this pass only connects the transport.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerService
|
||||
{
|
||||
Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
}
|
||||
@@ -66,4 +66,7 @@ public static class ErrorCodes
|
||||
public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED";
|
||||
public const string RunCostClosed = "RUN_COST_CLOSED";
|
||||
public const string RunNotCancellable = "RUN_NOT_CANCELLABLE";
|
||||
|
||||
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
|
||||
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=dbuser"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"RootPath": "App_Data/hr-documents",
|
||||
"MaxSizeBytes": 10485760
|
||||
},
|
||||
"GeneralLedgerService": {
|
||||
"BaseUrl": "https://localhost:7024/api/v1/",
|
||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -105,6 +105,31 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
|
||||
- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified.
|
||||
|
||||
## 7. External Integrations
|
||||
> **General Ledger service** (separate microservice, own repo/DB) — connected 2026-07-20 as a generic reverse-proxy only; no ERPCore business logic posts to it yet. Full contract + progress detail: `docs/12-GENERAL-LEDGER-INTEGRATION.md`.
|
||||
- [~] Generic proxy `GET|POST|PUT /api/v1/gl/{**path}` (`GeneralLedgerController` → `IGeneralLedgerService` → `IGeneralLedgerClient`) — forwards method/path/query/body/content-type verbatim to the GL service with a server-attached `X-Api-Key`; GL's response (status + body) returned unchanged. ErpAccess-door-policy-gated like every other v1 endpoint. Config: `GeneralLedgerService:BaseUrl`/`ApiKey` in `appsettings.json`. Build verified clean; **not yet live-smoke-tested** (no running GL instance this pass).
|
||||
- [ ] Internal wiring — ERPCore services (GRN confirm, adjustments, etc.) calling `IGeneralLedgerService` directly to post real journal entries. Deliberately deferred.
|
||||
|
||||
> ### 2026-07-20 — RBAC nav seed for the frontend's new "Ledgers" section
|
||||
> The `Frontend/PROGRESS.md` §8 "Ledgers" sidebar section (docs/21-GENERAL-LEDGER-FRONTEND.md) needs a matching `NavItem`/`SubNavItem`/`Permission` row for every entry, or the sidebar filters it out for every role regardless of the frontend change (docs/10 C.8, `GET /auth/me`'s `navCodes`). Added via `NavItemConfiguration.cs`/`SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` `HasData`: `NavItem` `ledgers` (id 11), 7 `SubNavItem` rows (ids 9–15, `ledgers.trial-balance` … `ledgers.bank-accounts`), 8 `Permission` rows (ids 19–26) — same one-`Permission`-per-nav-entry convention as every existing nav row. Migration `AddLedgersNavSeed`.
|
||||
> **Build note:** a locally running `ERPCore.exe` (PID 29692) held the default `bin/Debug` output locked for the whole session, so `dotnet ef migrations add` twice produced an empty no-op migration off a stale assembly (`--no-build` silently reused pre-edit code) before the real cause was found. Fixed by building to a scratch output directory (unaffected by the lock), copying the fresh `ERPCore.dll` over the locked `bin/Debug` copy (the running process only locks the `.exe`, not the `.dll`), then re-scaffolding — the resulting migration's `Up`/`Down` were verified by inspection against the identical, already-applied `AddRolesNavPermissions` migration's `InsertData`/`DeleteData` shape. The stray process was left running rather than killed, since it wasn't started by this work and may be in active use elsewhere.
|
||||
> **Not yet applied to a live database** — no Postgres instance was available in this pass to run `dotnet ef database update` against. `dotnet build` is clean (0 warnings/0 errors).
|
||||
> **Operational step still needed post-deploy (not code):** a new `NavItem`/`SubNavItem` carries no `RolePermission` grants by default — an administrator must check the new Ledgers permissions for the relevant role(s) via **Settings → Roles** before anyone sees the sidebar entry, same as every previous nav addition.
|
||||
|
||||
> ### 2026-07-30 — RBAC nav seed: 8th sub-item for the new "Tax Report" screen
|
||||
> The frontend's GL-revision pass (`docs/21-GENERAL-LEDGER-FRONTEND.md`, Frontend/PROGRESS.md §8) added a Tax Report screen to the Ledgers sidebar section — needs the same nav-seed treatment as every other entry (docs/10 C.8). Added `SubNavItem` id 16 (`ledgers.tax-report`, `/dashboard/ledgers/tax-report`, sort order 7) and `Permission` id 27 (`NAV:ledgers.tax-report`); re-sequenced the existing `ledgers.bank-accounts` row's `SortOrder` from 7→8 so Tax Report sits before it, matching the sidebar array's actual order. Migration `AddTaxReportNavSeed` — no locked-process issue this time (confirmed no stray `ERPCore.exe` running before scaffolding), generated cleanly on the first attempt with real `InsertData`/`UpdateData`/`DeleteData` (`Down()` correctly restores `bank-accounts`' `SortOrder` to 7). `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same open item as the original `AddLedgersNavSeed` migration; both are still pending `dotnet ef database update` against a real Postgres instance.
|
||||
|
||||
> ### 2026-07-30 (2) — Fixed a real `SubNavItemId`/`PermissionId` collision between Procurement and Ledgers seed data
|
||||
> **Root cause:** when the 2026-07-20 `AddLedgersNavSeed` migration was authored, its `SubNavItem`/`Permission` IDs were picked by looking at the *actual DB row count*, not the config source — but `SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` already had `HasData` entries for Procurement's 4 sub-items (`procurement.requisitions`/`.rfqs`/`.purchase-orders`/`.purchase-returns`, ids 9–12/19–22) that **had never actually been migrated into any database** (no migration `Up()` anywhere ever inserts them — confirmed by grep across every migration file). Ledgers then claimed the same ids (9–12 sub-nav, 19–22 permission) for its own rows, so the config ended up with two `HasData` entries sharing the same primary key per table. `ErpDbContextModelSnapshot.cs` had silently absorbed both (`dotnet ef migrations add` doesn't hard-fail on this at scaffold time), but **EF's runtime model validator does** — `dotnet ef migrations add` for anything touching these tables, and by extension normal app startup/first `DbContext` use, throws `InvalidOperationException: A seed entity ... has the same key value as another seed entity mapped to the same table`. This is very likely the crash the user was hitting.
|
||||
> **Fix:** moved Procurement's 4 sub-nav rows off the colliding ids onto **17–20** (`SubNavItemId`) and **28–31** (`PermissionId`), past every id already claimed by Ledgers/Tax-Report (max 16/27). Removed the phantom duplicate Procurement entries from `ErpDbContextModelSnapshot.cs` (they never reflected real DB state) so the differ could compute a clean diff, then generated migration **`FixProcurementNavIdCollision`** — pure `InsertData` for the 4 sub-nav rows + 4 permission rows at their new ids (this is also the *first* migration that actually creates Procurement's sub-nav-item/permission rows in the database at all). `Down()` is a clean `DeleteData` reversal.
|
||||
> **Verified:** running `dotnet ef migrations add` against the pre-fix config reproduced the exact `InvalidOperationException` above (scaffold failed outright, no migration file produced), confirming this was a real, reproducible crash and not a false alarm; after the fix, the same command succeeded and `dotnet ef migrations list` builds the full model with no error, listing all 7 migrations (the last 2 — `AddTaxReportNavSeed`, `FixProcurementNavIdCollision` — still `(Pending)`, no Postgres instance available this session); `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same standing blocker as the two prior nav-seed migrations.
|
||||
> Also fixed, same pass: `Frontend/erp-system/components/Layouts/AppSidebar.tsx`'s auto-expand-active-parent logic tripped `react-hooks/set-state-in-effect` (`setExpanded` called synchronously inside a `useEffect`) — converted to the same "adjust state during render" pattern used for the Ledgers report pages, keyed on a `pathname + item-codes` composite key (tracked via a `lastAutoExpandKey` state var) so it still re-fires once `items` populates after the RBAC `navCodes` fetch resolves. `npx eslint components/Layouts/AppSidebar.tsx` clean.
|
||||
|
||||
> ### 2026-07-31 — RBAC nav seed: new "Accounts" nav item (Cheque Management screens + Cash/Bank Accounts moved off Ledgers)
|
||||
> The frontend added a new "Accounts" sidebar section (`Frontend/PROGRESS.md` §8) for the new Cheque Management screens and to hold Cash/Bank Accounts, which moved out of Ledgers into it (user-requested — Cheque Books/Received Cheques/Cash-Bank Accounts are all the same kind of operational account bookkeeping, not a statutory report). Migration **`AddAccountsNavSeed`**: `InsertData` for `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books` id 21/33, `accounts.received-cheques` id 22/34); **`UpdateData`, not delete-and-recreate**, for the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) — same ids, just new `Code`/`Href`/`NavItemId` — so a role that had already been granted this permission under its old `ledgers.bank-accounts` code doesn't silently lose it just because the section changed. `Down()` correctly reverses both the inserts and the renamed-row update back to its Ledgers-era values.
|
||||
> **No locked-process issue avoided this time** — `ERPCore.exe` was found running twice during this pass (the user had restarted it between turns to test the Tax Report fix); confirmed with the user before killing it each time, per this session's standing caution around stopping their dev server. `dotnet build` clean (0 warnings/0 errors); `dotnet ef migrations list` shows all 8 migrations with none pending. **Applied to the live database this session** (`dotnet ef database update`) — unlike every prior nav-seed migration this session, this one did not have to wait for a live Postgres instance to become available.
|
||||
> **Operational step still needed post-deploy (not code):** same as every previous nav addition — an administrator must grant the new `NAV:accounts`/`NAV:accounts.cheque-books`/`NAV:accounts.received-cheques` permissions to the relevant role(s) via **Settings → Roles** before anyone sees the new sidebar entries (the re-homed `NAV:accounts.bank-accounts` keeps whatever grants it already had).
|
||||
|
||||
## Deferred (Phase 2+ — do NOT build now, hooks only)
|
||||
- [ ] Vendor invoice + three-way match
|
||||
- [ ] Reservation/allocation fulfilment
|
||||
|
||||
Reference in New Issue
Block a user